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 0000000..ea5bca3 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/ic_launcher_foreground.png differ 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 0000000..4d03e59 Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/splash.png differ 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 0000000..b6efbdb Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ 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