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