From 7900bf5b3e08bb7e56ba161174f3e6f6638adeef Mon Sep 17 00:00:00 2001 From: Astra Date: Sat, 3 Oct 2026 12:44:11 +0100 Subject: [PATCH] Android app (TWA) in android/, built with podman; serve assetlinks.json for it --- README.md | 22 +++++ android/.gitignore | 6 ++ android/Containerfile | 17 ++++ android/app/build.gradle | 53 ++++++++++++ android/app/src/main/AndroidManifest.xml | 80 ++++++++++++++++++ .../drawable-nodpi/ic_launcher_foreground.png | Bin 0 -> 8182 bytes .../src/main/res/drawable-nodpi/splash.png | Bin 0 -> 7751 bytes .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 ++ .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 5958 bytes android/app/src/main/res/values/colors.xml | 6 ++ android/app/src/main/res/values/strings.xml | 4 + android/app/src/main/res/xml/filepaths.xml | 4 + android/build.gradle | 3 + android/build.sh | 37 ++++++++ android/gradle.properties | 2 + android/settings.gradle | 16 ++++ example.env | 6 ++ internal/web/pwa.go | 29 +++++++ internal/web/pwa_test.go | 16 ++++ internal/web/server.go | 5 ++ main.go | 2 + 21 files changed, 313 insertions(+) create mode 100644 android/.gitignore create mode 100644 android/Containerfile create mode 100644 android/app/build.gradle create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/res/drawable-nodpi/ic_launcher_foreground.png create mode 100644 android/app/src/main/res/drawable-nodpi/splash.png create mode 100644 android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/values/colors.xml create mode 100644 android/app/src/main/res/values/strings.xml create mode 100644 android/app/src/main/res/xml/filepaths.xml create mode 100644 android/build.gradle create mode 100755 android/build.sh create mode 100644 android/gradle.properties create mode 100644 android/settings.gradle diff --git a/README.md b/README.md index c2be735..1feb55b 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,28 @@ rsvg-convert -w 512 -h 512 icon-maskable.svg -o icon-maskable-512.png rsvg-convert -w 180 -h 180 icon-maskable.svg -o apple-touch-icon.png ``` +### Android app + +`android/` is the site as an Android app: a Trusted Web Activity, which opens the public site in +the phone's Chrome, full screen, with no code of its own (Google's `androidbrowserhelper` does it +all). Build it with podman: + +``` +android/build.sh +``` + +That builds an image with JDK 17, the Android SDK and Gradle, and writes a signed +`android/out/simpleadmin-web.apk`. The signing key is kept outside the repo, in +`~/.config/simpleadmin-web/android/` (`release.jks` and its `password`), and made on the first run. +Back it up: an APK signed with another key won't install over the old one. Then: + +- Set `SAW_ANDROID_PACKAGE` and `SAW_ANDROID_CERT_SHA256` (the fingerprint `build.sh` prints) so + the panel serves `/.well-known/assetlinks.json`. Without it, Chrome shows its URL bar over the app. +- Bump `versionCode` in `android/app/build.gradle` for every APK you hand out. +- The site and start page are at the top of `android/app/build.gradle`; never change `applicationId`. + +The app needs Chrome (or another browser with TWA support) on the phone. + ## Limitations and caveats - **Tested against a real MySQL 8.4** with SimpleAdmin 1.8.2b's migrations applied, and a fake game diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..3977c1c --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,6 @@ +.gradle/ +build/ +app/build/ +out/ +local.properties +*.jks diff --git a/android/Containerfile b/android/Containerfile new file mode 100644 index 0000000..94a3e26 --- /dev/null +++ b/android/Containerfile @@ -0,0 +1,17 @@ +# Build image for the Android app: JDK 17, the Android SDK and Gradle. See build.sh. +FROM docker.io/library/eclipse-temurin:17-jdk + +ARG CMDLINE_TOOLS=15859902 +ARG GRADLE=8.14.3 +ENV ANDROID_HOME=/opt/android-sdk +ENV PATH=/opt/gradle/bin:$ANDROID_HOME/cmdline-tools/latest/bin:$PATH + +RUN apt-get update && apt-get install -y --no-install-recommends unzip && rm -rf /var/lib/apt/lists/* +RUN curl -fsSLo /tmp/tools.zip https://dl.google.com/android/repository/commandlinetools-linux-${CMDLINE_TOOLS}_latest.zip \ + && mkdir -p $ANDROID_HOME/cmdline-tools && unzip -q /tmp/tools.zip -d /tmp \ + && mv /tmp/cmdline-tools $ANDROID_HOME/cmdline-tools/latest && rm /tmp/tools.zip +RUN yes | sdkmanager --licenses >/dev/null && sdkmanager "platforms;android-36" "build-tools;35.0.0" "platform-tools" >/dev/null +RUN curl -fsSLo /tmp/gradle.zip https://services.gradle.org/distributions/gradle-${GRADLE}-bin.zip \ + && unzip -q /tmp/gradle.zip -d /opt && mv /opt/gradle-${GRADLE} /opt/gradle && rm /tmp/gradle.zip + +WORKDIR /src diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..6e45092 --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,53 @@ +plugins { + id "com.android.application" +} + +// The site the app opens. It must serve /.well-known/assetlinks.json naming this app's package and +// signing key, or Chrome shows its URL bar over the app (see SAW_ANDROID_* in example.env). +def siteURL = "https://cs2.zio.sh" +def startPath = "/" + +android { + namespace "sh.zio.cs2" + compileSdk 36 + + defaultConfig { + // Never change applicationId once installed anywhere: Android treats it as a different app. + applicationId "sh.zio.cs2" + minSdk 24 + targetSdk 36 + // Bump versionCode for every APK you hand out, or Android refuses to update over the old one. + versionCode 2 + versionName "1.0" + + resValue "string", "launch_url", siteURL + startPath + resValue "string", "asset_statements", + "[{\\\"relation\\\": [\\\"delegate_permission/common.handle_all_urls\\\"], \\\"target\\\": {\\\"namespace\\\": \\\"web\\\", \\\"site\\\": \\\"" + siteURL + "\\\"}}]" + } + + signingConfigs { + release { + storeFile file(System.getenv("KEYSTORE_FILE") ?: "/keystore/release.jks") + storePassword System.getenv("KEYSTORE_PASSWORD") + keyAlias "release" + keyPassword System.getenv("KEYSTORE_PASSWORD") + } + } + + buildTypes { + release { + minifyEnabled true + proguardFiles getDefaultProguardFile("proguard-android-optimize.txt") + signingConfig signingConfigs.release + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } +} + +dependencies { + implementation "com.google.androidbrowserhelper:androidbrowserhelper:2.7.3" +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..08474ae --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable-nodpi/ic_launcher_foreground.png b/android/app/src/main/res/drawable-nodpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..ea5bca30ca12ee30c3d6f400a60e1d45349f8ca5 GIT binary patch literal 8182 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 ROZK0Fkf++Jl~17W{}0cB6y^W` literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable-nodpi/splash.png b/android/app/src/main/res/drawable-nodpi/splash.png new file mode 100644 index 0000000000000000000000000000000000000000..4d03e59d2f04a43e50aad082412a5f73d588dcc1 GIT binary patch literal 7751 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 Zk*O)5&0w}i;3~s_wz{rbmCCb-{{k^ei}U~h literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..89f1c0d --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..b6efbdb9acc4f4b03ca4a94e5e2ac377d1ee66dc GIT binary patch literal 5958 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 C2!!|m literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..de6f9e3 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,6 @@ + + + + #1B1E25 + #14161B + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..3414f21 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + zio.sh + diff --git a/android/app/src/main/res/xml/filepaths.xml b/android/app/src/main/res/xml/filepaths.xml new file mode 100644 index 0000000..2818826 --- /dev/null +++ b/android/app/src/main/res/xml/filepaths.xml @@ -0,0 +1,4 @@ + + + + diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..42f62e3 --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,3 @@ +plugins { + id "com.android.application" version "8.13.2" apply false +} diff --git a/android/build.sh b/android/build.sh new file mode 100755 index 0000000..6a6ad5f --- /dev/null +++ b/android/build.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Builds the signed release APK into out/ with podman (no local JDK or Android SDK needed). +# +# The signing key lives outside the repo, in $KEYSTORE_DIR (default ~/.config/simpleadmin-web/android), +# and is created on the first run. Back it up: an APK signed with a different key can't update an +# installed one, and the panel's assetlinks.json names the key's fingerprint. +set -euo pipefail + +here="$(cd "$(dirname "$0")" && pwd)" +keydir="${KEYSTORE_DIR:-$HOME/.config/simpleadmin-web/android}" +image=simpleadmin-web-android-build + +podman build -q -t "$image" "$here" >/dev/null + +mkdir -p "$keydir" +chmod 700 "$keydir" +if [[ ! -f "$keydir/release.jks" ]]; then + echo "Creating a signing key in $keydir" + (umask 077 && openssl rand -hex 24 >"$keydir/password") + podman run --rm -v "$keydir":/keystore:Z "$image" keytool -genkeypair -noprompt \ + -keystore /keystore/release.jks -storetype PKCS12 -storepass "$(cat "$keydir/password")" \ + -alias release -keyalg RSA -keysize 4096 -validity 10000 -dname "CN=zio.sh" +fi + +podman run --rm \ + -v "$here":/src:Z \ + -v "$keydir":/keystore:ro,Z \ + -v simpleadmin-web-android-gradle:/root/.gradle \ + -e KEYSTORE_PASSWORD="$(cat "$keydir/password")" \ + "$image" gradle --no-daemon -q assembleRelease + +mkdir -p "$here/out" +cp "$here/app/build/outputs/apk/release/app-release.apk" "$here/out/simpleadmin-web.apk" +echo "APK: $here/out/simpleadmin-web.apk" +echo "Signing key SHA-256 (for SAW_ANDROID_CERT_SHA256):" +podman run --rm -v "$keydir":/keystore:ro,Z "$image" keytool -list -keystore /keystore/release.jks \ + -storepass "$(cat "$keydir/password")" -alias release | sed -n 's/.*SHA-256): //p' diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..660848f --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx2g +android.useAndroidX=true diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..26e116e --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,16 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} +rootProject.name = "simpleadmin-web" +include ":app" diff --git a/example.env b/example.env index c65a4b0..5fc4288 100644 --- a/example.env +++ b/example.env @@ -25,6 +25,12 @@ SAW_SA_CONFIG=/data/CS2-SimpleAdmin.json # Rank labels, colours and public descriptions. Built-in defaults cover the #rank/* groups. # SAW_SITE_CONFIG=/data/site.json +# The Android app (android/build.sh), for /.well-known/assetlinks.json. Without these the app still +# works, but Chrome shows its URL bar over it. build.sh prints the fingerprint; several are +# comma-separated (e.g. the Play signing key as well). +# SAW_ANDROID_PACKAGE=sh.zio.cs2 +# SAW_ANDROID_CERT_SHA256=AB:CD:... + # SAW_LISTEN=:8080 # SAW_POLL_SECONDS=5 # SAW_TIMEZONE=UTC (defaults to SimpleAdmin's Timezone setting) diff --git a/internal/web/pwa.go b/internal/web/pwa.go index 5dbd877..83bc364 100644 --- a/internal/web/pwa.go +++ b/internal/web/pwa.go @@ -4,6 +4,7 @@ import ( "encoding/json" "io/fs" "net/http" + "strings" ) // The site installs as an app (a PWA): a web app manifest, plus the service worker in @@ -62,6 +63,34 @@ func (s *Server) manifest(staff bool) http.HandlerFunc { } } +// assetLinks tells Chrome the Android app belongs to this site, so it opens the site full screen +// (a Trusted Web Activity) instead of with its URL bar showing. +func (s *Server) assetLinks(w http.ResponseWriter, r *http.Request) { + var certs []string + for _, c := range s.cfg.AndroidCerts { + if c = strings.ToUpper(strings.TrimSpace(c)); c != "" { + certs = append(certs, c) + } + } + if s.cfg.AndroidPackage == "" || len(certs) == 0 { + http.NotFound(w, r) + return + } + type target struct { + Namespace string `json:"namespace"` + PackageName string `json:"package_name"` + Fingerprints []string `json:"sha256_cert_fingerprints"` + } + type statement struct { + Relation []string `json:"relation"` + Target target `json:"target"` + } + writeJSON(w, http.StatusOK, []statement{{ + Relation: []string{"delegate_permission/common.handle_all_urls"}, + Target: target{Namespace: "android_app", PackageName: s.cfg.AndroidPackage, Fingerprints: certs}, + }}) +} + // serviceWorker serves sw.js from the root, since a worker only controls pages under its own path. func serviceWorker(static fs.FS) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/web/pwa_test.go b/internal/web/pwa_test.go index bc923af..d6e6614 100644 --- a/internal/web/pwa_test.go +++ b/internal/web/pwa_test.go @@ -44,3 +44,19 @@ func TestPWA(t *testing.T) { } get("/static/offline.html") } + +func TestAssetLinks(t *testing.T) { + serve := func(cfg Config) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + (&Server{cfg: cfg, site: defaultSite()}).Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/.well-known/assetlinks.json", nil)) + return rec + } + if rec := serve(Config{AndroidCerts: []string{""}}); rec.Code != http.StatusNotFound { + t.Errorf("unset: status %d", rec.Code) + } + rec := serve(Config{AndroidPackage: "sh.zio.cs2", AndroidCerts: []string{" aa:bb ", "", "CC:DD"}}) + want := `[{"relation":["delegate_permission/common.handle_all_urls"],"target":{"namespace":"android_app","package_name":"sh.zio.cs2","sha256_cert_fingerprints":["AA:BB","CC:DD"]}}]` + if got := strings.TrimSpace(rec.Body.String()); rec.Code != http.StatusOK || got != want { + t.Errorf("status %d, body %s", rec.Code, got) + } +} diff --git a/internal/web/server.go b/internal/web/server.go index 827e8d3..d5327f6 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -31,6 +31,10 @@ type Config struct { // Limits used when SimpleAdmin's config file isn't available. MaxBanDuration int MaxMuteDuration int + // The Android app (android/ in this repo), for /.well-known/assetlinks.json: its package and + // the SHA-256 fingerprints of the keys it's signed with. Empty serves no assetlinks.json. + AndroidPackage string + AndroidCerts []string } type Server struct { @@ -84,6 +88,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /manifest.webmanifest", s.manifest(false)) mux.HandleFunc("GET /admin.webmanifest", s.manifest(true)) mux.HandleFunc("GET /sw.js", serviceWorker(static)) + mux.HandleFunc("GET /.well-known/assetlinks.json", s.assetLinks) mux.HandleFunc("GET /auth/login", s.login) mux.HandleFunc("GET /auth/callback", s.callback) diff --git a/main.go b/main.go index 297b950..64a0005 100644 --- a/main.go +++ b/main.go @@ -160,6 +160,8 @@ func run() error { ServerAddress: joinAddr, MaxBanDuration: maxBan, MaxMuteDuration: maxMute, + AndroidPackage: env("SAW_ANDROID_PACKAGE", ""), + AndroidCerts: strings.Split(env("SAW_ANDROID_CERT_SHA256", ""), ","), }, st, rc, poller, sacfg) if err != nil { return err