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