Android app (TWA) in android/, built with podman; serve assetlinks.json for it
This commit is contained in:
parent
44708d5f3c
commit
7900bf5b3e
21 changed files with 313 additions and 0 deletions
22
README.md
22
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
|
||||
|
|
|
|||
6
android/.gitignore
vendored
Normal file
6
android/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
.gradle/
|
||||
build/
|
||||
app/build/
|
||||
out/
|
||||
local.properties
|
||||
*.jks
|
||||
17
android/Containerfile
Normal file
17
android/Containerfile
Normal 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
53
android/app/build.gradle
Normal 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"
|
||||
}
|
||||
80
android/app/src/main/AndroidManifest.xml
Normal file
80
android/app/src/main/AndroidManifest.xml
Normal 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 |
BIN
android/app/src/main/res/drawable-nodpi/splash.png
Normal file
BIN
android/app/src/main/res/drawable-nodpi/splash.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.6 KiB |
|
|
@ -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>
|
||||
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.8 KiB |
6
android/app/src/main/res/values/colors.xml
Normal file
6
android/app/src/main/res/values/colors.xml
Normal 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>
|
||||
4
android/app/src/main/res/values/strings.xml
Normal file
4
android/app/src/main/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">zio.sh</string>
|
||||
</resources>
|
||||
4
android/app/src/main/res/xml/filepaths.xml
Normal file
4
android/app/src/main/res/xml/filepaths.xml
Normal 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
3
android/build.gradle
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
plugins {
|
||||
id "com.android.application" version "8.13.2" apply false
|
||||
}
|
||||
37
android/build.sh
Executable file
37
android/build.sh
Executable 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'
|
||||
2
android/gradle.properties
Normal file
2
android/gradle.properties
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
org.gradle.jvmargs=-Xmx2g
|
||||
android.useAndroidX=true
|
||||
16
android/settings.gradle
Normal file
16
android/settings.gradle
Normal 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"
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
2
main.go
2
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue