Add the Android app: a WebView around public_html

A single Activity holding one WebView, in Java rather than Kotlin — for
~120 lines of setup Kotlin would only add its Gradle plugin, its stdlib
in the APK and a third version to keep aligned with Gradle and AGP. One
dependency: androidx.webkit 1.16.0.

The assets are served from https://appassets.androidplatform.net through
WebViewAssetLoader rather than file://. That is the part that matters: an
https origin is a secure context, and localStorage — which holds every
setting and the match in progress — is blocked on file://, so a saved
game would silently vanish. Confirmed on device: force-quit mid-match and
everything comes back.

public_html stays the only copy of the web app. A task syncs it into the
assets at build time, so the browser version and the app version cannot
drift; the APK's index.html hashes identical to the working tree. sw.js
is left out on purpose — it is cache-first, and a service-worker cache
outlives an app upgrade, so it would serve a stale index.html with no way
to clear it. The APK is the cache now.

Three web APIs don't exist in a WebView, so the Activity supplies them:
FLAG_KEEP_SCREEN_ON for navigator.wakeLock, native immersive mode for
requestFullscreen(), and LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES so the
panels' env(safe-area-inset-*) has something to report. textZoom is
pinned to 100: the layout is built on clamp() and vw, and a large system
font would scale the text inside it.

The manifest declares no permissions whatsoever.

Wiring note: AGP 9 refuses a Provider in sourceSets.assets.srcDir, since
it can't tell generated from static directories. The sync is a task with
a DirectoryProperty output, attached via androidComponents.onVariants,
which is the supported path and carries the task dependency.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 19:10:38 +02:00
co-authored by Claude Opus 5
parent 6069ef270e
commit 9f2081a0c9
17 changed files with 653 additions and 0 deletions
+78
View File
@@ -0,0 +1,78 @@
import javax.inject.Inject
plugins {
id("com.android.application")
}
android {
namespace = "nl.hansdezwart.bgclock"
compileSdk = 36
buildToolsVersion = "36.0.0"
defaultConfig {
applicationId = "nl.hansdezwart.bgclock"
minSdk = 26
targetSdk = 36
versionCode = 1
versionName = "1.0"
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildTypes {
release {
isMinifyEnabled = false
}
}
}
/**
* The web app is not copied into the repo — it is read out of public_html at
* build time, so the browser version and the app version cannot drift.
*
* sw.js is left behind on purpose: it is cache-first, and a service-worker cache
* outlives an app upgrade, so it would go on serving the old index.html with no
* way to clear it. Without the file the registration fails into its own .catch,
* and the APK is the cache instead.
*/
abstract class SyncWebAssets : DefaultTask() {
@get:InputDirectory
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val source: DirectoryProperty
@get:OutputDirectory
abstract val outputDir: DirectoryProperty
@get:Inject
abstract val fs: FileSystemOperations
@TaskAction
fun run() {
fs.sync {
from(source) { exclude("sw.js") }
into(outputDir)
}
}
}
val webAssets = tasks.register<SyncWebAssets>("syncWebAssets") {
source.fileValue(rootProject.file("../public_html"))
}
// the Variant API, not sourceSets.assets.srcDir: AGP 9 refuses a Provider there
// because it can't tell generated from static, and this way the task dependency
// is carried automatically
androidComponents {
onVariants { variant ->
variant.sources.assets?.addGeneratedSourceDirectory(webAssets, SyncWebAssets::outputDir)
}
}
dependencies {
// the only dependency: WebViewAssetLoader, which serves the assets over a
// real https origin so they count as a secure context
implementation("androidx.webkit:webkit:1.16.0")
}
+30
View File
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- This app declares no permissions at all, deliberately. The clock lives
entirely inside the APK and never makes a request, so it needs no INTERNET;
there are no haptics, so it needs no VIBRATE. Keep it that way: the empty
permission list is worth more than any feature that would spend it. -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.BgClock">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:screenOrientation="portrait"
android:resizeableActivity="false"
android:windowSoftInputMode="stateAlwaysHidden"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|uiMode|density|fontScale|navigation|touchscreen">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,141 @@
package nl.hansdezwart.bgclock;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.os.Build;
import android.os.Bundle;
import android.view.View;
import android.view.WindowInsets;
import android.view.WindowInsetsController;
import android.view.WindowManager;
import android.webkit.WebResourceRequest;
import android.webkit.WebResourceResponse;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import androidx.webkit.WebViewAssetLoader;
/**
* The whole app: one WebView holding the clock from public_html.
*
* The assets are served over https://appassets.androidplatform.net rather than
* file:// because that is a secure context — localStorage, which holds every
* setting and the match in progress, is blocked on file:// and the saved game
* would silently vanish.
*/
public class MainActivity extends Activity {
private static final String ORIGIN = "https://appassets.androidplatform.net";
private static final int INK = 0xFF332E2B; // --bar, so there is no white flash
private WebView web;
@SuppressLint("SetJavaScriptEnabled")
@Override
protected void onCreate(Bundle savedState) {
super.onCreate(savedState);
// in a browser the page holds a screen wake lock; navigator.wakeLock
// does not exist in a WebView, so the window keeps the screen on. The
// page requests it unconditionally at boot, so always-on is parity.
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
// draw behind the cutout, so the CSS env(safe-area-inset-*) the panels
// already use has something to report
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
getWindow().getAttributes().layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES;
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
getWindow().setDecorFitsSystemWindows(false);
}
final WebViewAssetLoader loader = new WebViewAssetLoader.Builder()
.addPathHandler("/assets/", new WebViewAssetLoader.AssetsPathHandler(this))
.build();
web = new WebView(this);
web.setBackgroundColor(INK);
web.setOverScrollMode(View.OVER_SCROLL_NEVER); // no rubber-band glow on a fixed page
web.setVerticalScrollBarEnabled(false);
web.setHorizontalScrollBarEnabled(false);
WebSettings s = web.getSettings();
s.setJavaScriptEnabled(true);
s.setDomStorageEnabled(true); // localStorage: settings and the live match
s.setMediaPlaybackRequiresUserGesture(false); // the Web Audio tick after a resume
s.setSupportZoom(false);
s.setBuiltInZoomControls(false);
// the layout is built on clamp() and vw; a large system font setting
// would scale the text inside it and break the two big readouts
s.setTextZoom(100);
web.setWebViewClient(new WebViewClient() {
@Override
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
return loader.shouldInterceptRequest(request.getUrl());
}
@Override
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
// the clock has no outbound links and no network permission;
// refuse anything that isn't our own assets, whatever happens
return !request.getUrl().toString().startsWith(ORIGIN);
}
});
setContentView(web);
web.loadUrl(ORIGIN + "/assets/index.html");
}
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if (hasFocus) hideSystemBars();
}
/** The page calls requestFullscreen(), which a WebView ignores. This is that. */
private void hideSystemBars() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
WindowInsetsController c = getWindow().getInsetsController();
if (c != null) {
c.hide(WindowInsets.Type.systemBars());
c.setSystemBarsBehavior(
WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
}
} else {
getWindow().getDecorView().setSystemUiVisibility(
View.SYSTEM_UI_FLAG_LAYOUT_STABLE
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
| View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_FULLSCREEN
| View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY);
}
}
// forwarded so the page's visibilitychange fires — that is what calls
// saveGame() and nudges the audio context back after backgrounding
@Override
protected void onPause() {
super.onPause();
if (web != null) web.onPause();
}
@Override
protected void onResume() {
super.onResume();
if (web != null) web.onResume();
}
@Override
protected void onDestroy() {
if (web != null) {
setContentView(new View(this)); // detach before destroying
web.destroy();
web = null;
}
super.onDestroy();
}
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated from public_html/icon-maskable-512.png. That icon is drawn for the
web maskable safe zone (the middle 80%); an adaptive icon only guarantees the
middle 66.7%. Scaling the art to 360 on a 432 canvas (0.667/0.80) lands its
safe circle exactly on Android's, so no mask shape can clip it. -->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ink" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
<monochrome android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ink" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
<monochrome android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- the CSS var named bar in index.html: the control bar, and the colour
behind the launcher icon -->
<color name="ink">#332E2B</color>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- matches short_name in manifest.webmanifest -->
<string name="app_name">BG Clock</string>
</resources>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- the platform Material theme, so the app needs no appcompat -->
<style name="Theme.BgClock" parent="@android:style/Theme.Material.NoActionBar">
<!-- the window is this colour before the WebView paints, so the launch
never flashes white -->
<item name="android:windowBackground">@color/ink</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
</style>
</resources>