diff --git a/android/README.md b/android/README.md
index bbbceef..0c47810 100644
--- a/android/README.md
+++ b/android/README.md
@@ -14,7 +14,7 @@ see [Android App](../docs/android-app.md).
| **Published build** | [`mc-webui-wrapper.apk`](mc-webui-wrapper.apk) |
| **Package** | `it.wojtaszek.mc.wrapper` |
| **Min / target SDK** | 21 (Android 5.0) / 34 |
-| **Permissions** | `INTERNET`; `CAMERA` for QR scanning; `WRITE_EXTERNAL_STORAGE` (Android 9 and older) for saving downloads |
+| **Permissions** | `INTERNET`; `CAMERA` for QR scanning; `POST_NOTIFICATIONS` for new-message alerts; `WRITE_EXTERNAL_STORAGE` (Android 9 and older) for saving downloads |
## Layout
@@ -26,6 +26,7 @@ src/
├── build.gradle.kts
└── src/main/
├── AndroidManifest.xml
+ ├── assets/notification_shim.js ← window.Notification
├── java/it/wojtaszek/mc/wrapper/MainActivity.kt ← the whole app
└── res/ layout, strings, icons
```
@@ -41,6 +42,30 @@ src/
- The page's camera request (QR scanning) is mirrored to an Android permission
request; downloads go to the phone's Downloads folder via `DownloadManager`
+## Notifications
+
+Android's WebView ships **no Web Notifications API at all** — `window.Notification`
+is simply undefined, so mc-webui detects that and greys its notification toggle
+out as "Unavailable". Native code has to supply the missing piece:
+
+- `assets/notification_shim.js` defines a stand-in `window.Notification` and
+ forwards every call to a `@JavascriptInterface` bridge in `MainActivity.kt`,
+ which posts through Android's own `NotificationManager`. **mc-webui itself
+ needs no changes** — the page uses the standard API and never knows
+- The shim is injected from `onPageStarted`, which runs as the document begins
+ loading. That is comfortably ahead of `DOMContentLoaded`, where mc-webui reads
+ the permission and decides whether to enable the toggle
+- The web permission states map onto Android's: below Android 13 posting needs
+ no permission so it is `granted` outright; after a refusal,
+ `shouldShowRequestPermissionRationale` is what separates "ask again"
+ (`default`) from "blocked for good" (`denied`)
+- The notification channel is created at startup, which is also what puts the
+ app in Android's notification settings list at all
+- **Limitation:** notifications only arrive while the app's process is alive —
+ open, or recently backgrounded. Android eventually suspends it and they stop.
+ This is the same limit the PWA has; real background delivery would need a
+ foreground service or push from the server
+
## Building
Open `src/` in Android Studio (the Gradle wrapper JAR is not checked in —
diff --git a/android/src/app/build.gradle.kts b/android/src/app/build.gradle.kts
index 32e61de..96d95e2 100644
--- a/android/src/app/build.gradle.kts
+++ b/android/src/app/build.gradle.kts
@@ -11,8 +11,8 @@ android {
applicationId = "it.wojtaszek.mc.wrapper"
minSdk = 21
targetSdk = 34
- versionCode = 1
- versionName = "1.0"
+ versionCode = 2
+ versionName = "1.1"
}
buildTypes {
diff --git a/android/src/app/src/main/AndroidManifest.xml b/android/src/app/src/main/AndroidManifest.xml
index 66ca731..ec6df96 100644
--- a/android/src/app/src/main/AndroidManifest.xml
+++ b/android/src/app/src/main/AndroidManifest.xml
@@ -4,6 +4,9 @@
+
+
+ android:exported="true"
+ android:launchMode="singleTop">
diff --git a/android/src/app/src/main/assets/notification_shim.js b/android/src/app/src/main/assets/notification_shim.js
new file mode 100644
index 0000000..2bd7a3c
--- /dev/null
+++ b/android/src/app/src/main/assets/notification_shim.js
@@ -0,0 +1,112 @@
+/*
+ * Web Notifications API for Android's WebView, which ships none of its own.
+ *
+ * Without this, `window.Notification` is undefined, mc-webui detects that and
+ * greys its notification toggle out as "Unavailable". Here we put back just
+ * enough of the standard API for the page to use unchanged - every call ends
+ * up at the native notification manager through `__mcNotifyBridge`.
+ *
+ * Runs at document start, before any of the page's own scripts.
+ */
+(function () {
+ 'use strict';
+
+ var bridge = window.__mcNotifyBridge;
+ // No bridge means an older wrapper: leave Notification undefined so the
+ // page falls back to "Unavailable" rather than failing halfway through
+ if (!bridge) return;
+
+ /** Pending requestPermission() promises, by callback id. */
+ var pending = {};
+ var nextCallbackId = 0;
+
+ /** Notifications still on screen, so a tap can find its onclick handler. */
+ var live = {};
+ var nextTag = 0;
+
+ /** Called from Kotlin once the Android permission dialog is answered. */
+ window.__mcNotifyResolve = function (callbackId, permission) {
+ var resolve = pending[callbackId];
+ if (!resolve) return;
+ delete pending[callbackId];
+ resolve(permission);
+ };
+
+ /** Called from Kotlin when the user taps a notification. */
+ window.__mcNotifyClicked = function (tag) {
+ var notification = live[tag];
+ if (!notification || typeof notification.onclick !== 'function') return;
+ try {
+ notification.onclick.call(notification);
+ } catch (e) {
+ console.error('mc-webui: notification onclick failed', e);
+ }
+ };
+
+ function McNotification(title, options) {
+ options = options || {};
+ this.title = String(title == null ? '' : title);
+ this.body = String(options.body == null ? '' : options.body);
+ this.icon = options.icon;
+ this.badge = options.badge;
+ this.silent = !!options.silent;
+ this.requireInteraction = !!options.requireInteraction;
+ // A tag replaces the previous notification with the same name, so an
+ // untagged one needs its own id rather than silently replacing another
+ this.tag = options.tag ? String(options.tag) : 'mc-auto-' + (nextTag++);
+
+ this.onclick = null;
+ this.onclose = null;
+ this.onerror = null;
+ this.onshow = null;
+
+ live[this.tag] = this;
+ try {
+ bridge.notify(this.tag, this.title, this.body, this.silent);
+ } catch (e) {
+ console.error('mc-webui: native notify failed', e);
+ }
+ }
+
+ McNotification.prototype.close = function () {
+ delete live[this.tag];
+ try {
+ bridge.close(this.tag);
+ } catch (e) {
+ console.error('mc-webui: native close failed', e);
+ }
+ };
+
+ // Read straight from Android every time - the user can revoke the
+ // permission in system settings while the page stays open
+ Object.defineProperty(McNotification, 'permission', {
+ get: function () {
+ try {
+ return bridge.getPermission();
+ } catch (e) {
+ return 'denied';
+ }
+ }
+ });
+
+ McNotification.requestPermission = function (legacyCallback) {
+ var promise = new Promise(function (resolve) {
+ var id = String(nextCallbackId++);
+ pending[id] = resolve;
+ try {
+ bridge.requestPermission(id);
+ } catch (e) {
+ delete pending[id];
+ resolve('denied');
+ }
+ });
+ // The pre-promise signature is still allowed by the spec
+ if (typeof legacyCallback === 'function') promise.then(legacyCallback);
+ return promise;
+ };
+
+ // Notification actions need a service worker, which WebView cannot do
+ McNotification.maxActions = 0;
+
+ window.Notification = McNotification;
+})();
diff --git a/android/src/app/src/main/java/it/wojtaszek/mc/wrapper/MainActivity.kt b/android/src/app/src/main/java/it/wojtaszek/mc/wrapper/MainActivity.kt
index b974777..879fd60 100644
--- a/android/src/app/src/main/java/it/wojtaszek/mc/wrapper/MainActivity.kt
+++ b/android/src/app/src/main/java/it/wojtaszek/mc/wrapper/MainActivity.kt
@@ -1,12 +1,17 @@
package it.wojtaszek.mc.wrapper
import android.Manifest
+import android.annotation.SuppressLint
import android.app.DownloadManager
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.content.pm.PackageManager
+import android.graphics.Bitmap
import android.net.Uri
import android.net.http.SslError
import android.os.Build
@@ -14,6 +19,7 @@ import android.os.Bundle
import android.os.Environment
import android.view.View
import android.webkit.CookieManager
+import android.webkit.JavascriptInterface
import android.webkit.PermissionRequest
import android.webkit.SslErrorHandler
import android.webkit.URLUtil
@@ -32,7 +38,10 @@ import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
+import org.json.JSONObject
/**
* A thin wrapper around the user's own mc-webui instance: one screen to enter
@@ -55,6 +64,14 @@ class MainActivity : AppCompatActivity() {
/** Download the page asked for, waiting for the storage permission on older Android. */
private var pendingDownload: (() -> Unit)? = null
+ private val notificationBridge = NotificationBridge()
+
+ /** Ids of `Notification.requestPermission()` promises awaiting the Android dialog. */
+ private val pendingPermissionCallbacks = mutableListOf()
+
+ /** The notification shim, injected into every page as it starts loading. */
+ private var pageStartScript: String? = null
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
@@ -65,6 +82,7 @@ class MainActivity : AppCompatActivity() {
webView = findViewById(R.id.webView)
urlInput = findViewById(R.id.urlInput)
+ createNotificationChannel()
setUpWebView()
findViewById