Android app (TWA) in android/, built with podman; serve assetlinks.json for it

This commit is contained in:
Astra 2026-10-03 12:44:11 +01:00
parent 44708d5f3c
commit 7900bf5b3e
21 changed files with 313 additions and 0 deletions

View file

@ -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

6
android/.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
.gradle/
build/
app/build/
out/
local.properties
*.jks

17
android/Containerfile Normal file
View file

@ -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

53
android/app/build.gradle Normal file
View file

@ -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"
}

View file

@ -0,0 +1,80 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- A Trusted Web Activity: the app is the panel, opened full screen in the phone's Chrome.
androidbrowserhelper's LauncherActivity does all of it; there is no code of our own. -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:manageSpaceActivity="com.google.androidbrowserhelper.trusted.ManageDataLauncherActivity"
android:supportsRtl="true"
android:theme="@android:style/Theme.Translucent.NoTitleBar">
<meta-data
android:name="asset_statements"
android:resource="@string/asset_statements" />
<activity
android:name="com.google.androidbrowserhelper.trusted.LauncherActivity"
android:exported="true"
android:label="@string/app_name">
<meta-data
android:name="android.support.customtabs.trusted.DEFAULT_URL"
android:value="@string/launch_url" />
<meta-data
android:name="android.support.customtabs.trusted.STATUS_BAR_COLOR"
android:resource="@color/theme" />
<meta-data
android:name="android.support.customtabs.trusted.NAVIGATION_BAR_COLOR"
android:resource="@color/background" />
<meta-data
android:name="android.support.customtabs.trusted.SPLASH_IMAGE_DRAWABLE"
android:resource="@drawable/splash" />
<meta-data
android:name="android.support.customtabs.trusted.SPLASH_SCREEN_BACKGROUND_COLOR"
android:resource="@color/background" />
<meta-data
android:name="android.support.customtabs.trusted.FILE_PROVIDER_AUTHORITY"
android:value="sh.zio.cs2.fileprovider" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- LauncherActivity uses these library activities by name, so they must be declared here
(as Bubblewrap's template does), or R8 renames them and the app crashes on launch.
ManageDataLauncherActivity is "Manage space" in Android's app info: the site's data. -->
<activity
android:name="com.google.androidbrowserhelper.trusted.ManageDataLauncherActivity"
android:enabled="true"
android:excludeFromRecents="true"
android:exported="false">
<meta-data
android:name="android.support.customtabs.trusted.MANAGE_SPACE_URL"
android:value="@string/launch_url" />
<intent-filter>
<action android:name="android.intent.action.APPLICATION_PREFERENCES" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<activity android:name="com.google.androidbrowserhelper.trusted.FocusActivity" />
<!-- Used when the phone has no browser that supports TWAs. -->
<activity
android:name="com.google.androidbrowserhelper.trusted.WebViewFallbackActivity"
android:configChanges="orientation|screenSize" />
<!-- Hands the splash image to Chrome while the panel loads. -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="sh.zio.cs2.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/filepaths" />
</provider>
</application>
</manifest>

Binary file not shown.

After

Width:  |  Height:  |  Size: 8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The panel's colours: the top bar (theme) and page background in styles.css. -->
<resources>
<color name="theme">#1B1E25</color>
<color name="background">#14161B</color>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">zio.sh</string>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<files-path name="twa_splash" path="twa_splash/" />
</paths>

3
android/build.gradle Normal file
View file

@ -0,0 +1,3 @@
plugins {
id "com.android.application" version "8.13.2" apply false
}

37
android/build.sh Executable file
View file

@ -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'

View file

@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx2g
android.useAndroidX=true

16
android/settings.gradle Normal file
View file

@ -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"

View file

@ -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)

View file

@ -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) {

View file

@ -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)
}
}

View file

@ -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)

View file

@ -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