feat(android): give the wrapper working notifications

Android's WebView ships no Web Notifications API, so window.Notification
was undefined, mc-webui detected that and greyed its toggle out as
"Unavailable". The app also declared no POST_NOTIFICATIONS and created no
channel, so it never even appeared in Android's notification settings.

A shim injected at document start puts window.Notification back and
forwards it to a @JavascriptInterface bridge that posts through Android's
NotificationManager. mc-webui itself is untouched - the page keeps using
the standard API.

- onPageStarted is early enough: mc-webui reads the permission on
  DOMContentLoaded, a whole parse and script pass later
- web permission states map onto Android's, with
  shouldShowRequestPermissionRationale separating "ask again" from
  "blocked for good" after a refusal
- tags replace notifications the way the web API expects; a tap returns to
  the running app (singleTop) and fires the page's onclick
- notifications only arrive while the process is alive, same as the PWA

versionCode 2 / versionName 1.1. Verified: debug and release both build
clean (lintVitalRelease included), shim and drawable land in the APK, and
the shim's contract is covered by a Node harness against a fake bridge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-07-29 08:48:01 +02:00
parent 6ecdfd6033
commit 954e99b42c
9 changed files with 362 additions and 12 deletions
+26 -1
View File
@@ -14,7 +14,7 @@ see [Android App](../docs/android-app.md).
| **Published build** | [`mc-webui-wrapper.apk`](mc-webui-wrapper.apk) | | **Published build** | [`mc-webui-wrapper.apk`](mc-webui-wrapper.apk) |
| **Package** | `it.wojtaszek.mc.wrapper` | | **Package** | `it.wojtaszek.mc.wrapper` |
| **Min / target SDK** | 21 (Android 5.0) / 34 | | **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 ## Layout
@@ -26,6 +26,7 @@ src/
├── build.gradle.kts ├── build.gradle.kts
└── src/main/ └── src/main/
├── AndroidManifest.xml ├── AndroidManifest.xml
├── assets/notification_shim.js ← window.Notification
├── java/it/wojtaszek/mc/wrapper/MainActivity.kt ← the whole app ├── java/it/wojtaszek/mc/wrapper/MainActivity.kt ← the whole app
└── res/ layout, strings, icons └── res/ layout, strings, icons
``` ```
@@ -41,6 +42,30 @@ src/
- The page's camera request (QR scanning) is mirrored to an Android permission - The page's camera request (QR scanning) is mirrored to an Android permission
request; downloads go to the phone's Downloads folder via `DownloadManager` 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 ## Building
Open `src/` in Android Studio (the Gradle wrapper JAR is not checked in — Open `src/` in Android Studio (the Gradle wrapper JAR is not checked in —
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "it.wojtaszek.mc.wrapper" applicationId = "it.wojtaszek.mc.wrapper"
minSdk = 21 minSdk = 21
targetSdk = 34 targetSdk = 34
versionCode = 1 versionCode = 2
versionName = "1.0" versionName = "1.1"
} }
buildTypes { buildTypes {
+5 -1
View File
@@ -4,6 +4,9 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<!-- QR scanning in Add Contact; the page asks first, Android asks second --> <!-- QR scanning in Add Contact; the page asks first, Android asks second -->
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<!-- New message alerts. Also what puts the app in Android's notification
settings at all - without it the app is not even listed there -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Saving downloads (e.g. database backups) before Android 10 --> <!-- Saving downloads (e.g. database backups) before Android 10 -->
<uses-permission <uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:name="android.permission.WRITE_EXTERNAL_STORAGE"
@@ -25,7 +28,8 @@
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:configChanges="orientation|screenSize|keyboardHidden" android:configChanges="orientation|screenSize|keyboardHidden"
android:exported="true"> android:exported="true"
android:launchMode="singleTop">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
@@ -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;
})();
@@ -1,12 +1,17 @@
package it.wojtaszek.mc.wrapper package it.wojtaszek.mc.wrapper
import android.Manifest import android.Manifest
import android.annotation.SuppressLint
import android.app.DownloadManager import android.app.DownloadManager
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.net.Uri import android.net.Uri
import android.net.http.SslError import android.net.http.SslError
import android.os.Build import android.os.Build
@@ -14,6 +19,7 @@ import android.os.Bundle
import android.os.Environment import android.os.Environment
import android.view.View import android.view.View
import android.webkit.CookieManager import android.webkit.CookieManager
import android.webkit.JavascriptInterface
import android.webkit.PermissionRequest import android.webkit.PermissionRequest
import android.webkit.SslErrorHandler import android.webkit.SslErrorHandler
import android.webkit.URLUtil import android.webkit.URLUtil
@@ -32,7 +38,10 @@ import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import org.json.JSONObject
/** /**
* A thin wrapper around the user's own mc-webui instance: one screen to enter * 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. */ /** Download the page asked for, waiting for the storage permission on older Android. */
private var pendingDownload: (() -> Unit)? = null private var pendingDownload: (() -> Unit)? = null
private val notificationBridge = NotificationBridge()
/** Ids of `Notification.requestPermission()` promises awaiting the Android dialog. */
private val pendingPermissionCallbacks = mutableListOf<String>()
/** The notification shim, injected into every page as it starts loading. */
private var pageStartScript: String? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main) setContentView(R.layout.activity_main)
@@ -65,6 +82,7 @@ class MainActivity : AppCompatActivity() {
webView = findViewById(R.id.webView) webView = findViewById(R.id.webView)
urlInput = findViewById(R.id.urlInput) urlInput = findViewById(R.id.urlInput)
createNotificationChannel()
setUpWebView() setUpWebView()
findViewById<Button>(R.id.saveButton).setOnClickListener { findViewById<Button>(R.id.saveButton).setOnClickListener {
@@ -88,6 +106,19 @@ class MainActivity : AppCompatActivity() {
if (savedUrl.isNullOrEmpty()) showConfig(null) else connect(savedUrl) if (savedUrl.isNullOrEmpty()) showConfig(null) else connect(savedUrl)
} }
/**
* A notification tap on a running app lands here rather than in [onCreate].
* When the app was not running, simply being launched is the whole point of
* the tap, so there is nothing extra to deliver.
*/
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
val tag = intent.getStringExtra(EXTRA_CLICKED_TAG) ?: return
intent.removeExtra(EXTRA_CLICKED_TAG)
callJs("window.__mcNotifyClicked", tag)
}
// ---------------------------------------------------------------- screens // ---------------------------------------------------------------- screens
/** /**
@@ -136,10 +167,18 @@ class MainActivity : AppCompatActivity() {
cacheMode = WebSettings.LOAD_DEFAULT cacheMode = WebSettings.LOAD_DEFAULT
} }
installNotificationShim()
webView.webViewClient = object : WebViewClient() { webView.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean = override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean =
handleUrl(request?.url) handleUrl(request?.url)
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
super.onPageStarted(view, url, favicon)
// Puts window.Notification in place before the page looks for it
pageStartScript?.let { view?.evaluateJavascript(it, null) }
}
// Deprecated, but it is the only one Android 5.x calls // Deprecated, but it is the only one Android 5.x calls
@Suppress("DEPRECATION", "OVERRIDE_DEPRECATION") @Suppress("DEPRECATION", "OVERRIDE_DEPRECATION")
override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean = override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean =
@@ -210,6 +249,136 @@ class MainActivity : AppCompatActivity() {
} }
} }
// ---------------------------------------------------------- notifications
/**
* Android's WebView implements no Web Notifications API at all, so mc-webui
* sees `window.Notification` missing and greys its toggle out. We hand the
* page a stand-in that forwards to Android's own notifications instead.
*
* The shim has to be in place before mc-webui reads the permission on
* DOMContentLoaded, or the toggle stays greyed out for the rest of the
* page's life. onPageStarted fires as the document begins loading, a whole
* parse and script pass ahead of that, so it is early enough.
*/
private fun installNotificationShim() {
webView.addJavascriptInterface(notificationBridge, BRIDGE_NAME)
pageStartScript = try {
assets.open(SHIM_ASSET).bufferedReader().use { it.readText() }
} catch (e: Exception) {
null
}
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val channel = NotificationChannel(
CHANNEL_ID,
getString(R.string.notification_channel_name),
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = getString(R.string.notification_channel_description)
}
(getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
.createNotificationChannel(channel)
}
/** Runs `fn(arg)` in the page, with the argument safely quoted. */
private fun callJs(fn: String, vararg args: String) {
val quoted = args.joinToString(", ") { JSONObject.quote(it) }
webView.evaluateJavascript("if (typeof $fn === 'function') $fn($quoted);", null)
}
private fun resolvePermission(callbackId: String) {
callJs("window.__mcNotifyResolve", callbackId, notificationBridge.getPermission())
}
/**
* The page's `Notification` object, on the Android side. Only the methods
* mc-webui actually calls are annotated, and nothing else is reachable from
* JavaScript - worth keeping that way, since the WebView loads whatever
* address the user typed in.
*/
private inner class NotificationBridge {
/**
* Mirrors the web permission model onto Android's. Before Android 13
* posting notifications needed no permission at all, so it is granted
* by definition; after a refusal `shouldShowRequestPermissionRationale`
* is what separates "ask again" from "blocked for good".
*/
@JavascriptInterface
fun getPermission(): String = when {
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU -> "granted"
hasPermission(Manifest.permission.POST_NOTIFICATIONS) -> "granted"
!prefs.getBoolean(KEY_ASKED_NOTIFICATIONS, false) -> "default"
ActivityCompat.shouldShowRequestPermissionRationale(
this@MainActivity, Manifest.permission.POST_NOTIFICATIONS
) -> "default"
else -> "denied"
}
@JavascriptInterface
fun requestPermission(callbackId: String) = runOnUiThread {
if (getPermission() != "default") {
resolvePermission(callbackId)
return@runOnUiThread
}
pendingPermissionCallbacks.add(callbackId)
ActivityCompat.requestPermissions(
this@MainActivity, arrayOf(Manifest.permission.POST_NOTIFICATIONS), REQ_NOTIFICATIONS
)
}
@JavascriptInterface
fun notify(tag: String, title: String, body: String, silent: Boolean) = runOnUiThread {
postNotification(tag, title, body, silent)
}
@JavascriptInterface
fun close(tag: String) = runOnUiThread {
NotificationManagerCompat.from(this@MainActivity).cancel(tag, NOTIFICATION_ID)
}
}
// areNotificationsEnabled() is false whenever POST_NOTIFICATIONS is missing,
// so the guard below is the permission check - lint just cannot see that
@SuppressLint("MissingPermission")
private fun postNotification(tag: String, title: String, body: String, silent: Boolean) {
val manager = NotificationManagerCompat.from(this)
// Covers both the runtime permission and the channel being switched off
if (!manager.areNotificationsEnabled()) return
// singleTop plus SINGLE_TOP means a tap returns to the running app
// instead of starting a second copy of it
val intent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
putExtra(EXTRA_CLICKED_TAG, tag)
}
var flags = PendingIntent.FLAG_UPDATE_CURRENT
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) flags = flags or PendingIntent.FLAG_IMMUTABLE
val contentIntent = PendingIntent.getActivity(this, tag.hashCode(), intent, flags)
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(body)
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setSilent(silent)
.setAutoCancel(true)
.setContentIntent(contentIntent)
.build()
try {
// The tag makes a new notification replace the matching old one,
// exactly as the page expects from the web API
manager.notify(tag, NOTIFICATION_ID, notification)
} catch (e: SecurityException) {
// Permission revoked between the check above and here
}
}
// -------------------------------------------------------------- downloads // -------------------------------------------------------------- downloads
private fun download(url: String, userAgent: String?, contentDisposition: String?, mimeType: String?) { private fun download(url: String, userAgent: String?, contentDisposition: String?, mimeType: String?) {
@@ -269,13 +438,32 @@ class MainActivity : AppCompatActivity() {
if (granted) download?.invoke() if (granted) download?.invoke()
else Toast.makeText(this, R.string.storage_denied, Toast.LENGTH_LONG).show() else Toast.makeText(this, R.string.storage_denied, Toast.LENGTH_LONG).show()
} }
REQ_NOTIFICATIONS -> {
// Recorded before resolving, so "not asked yet" and "refused"
// stop looking the same to getPermission()
prefs.edit().putBoolean(KEY_ASKED_NOTIFICATIONS, true).apply()
val waiting = pendingPermissionCallbacks.toList()
pendingPermissionCallbacks.clear()
waiting.forEach { resolvePermission(it) }
if (!granted) {
Toast.makeText(this, R.string.notifications_denied, Toast.LENGTH_LONG).show()
}
}
} }
} }
companion object { companion object {
private const val PREFS = "MC_PREFS" private const val PREFS = "MC_PREFS"
private const val KEY_URL = "SERVER_URL" private const val KEY_URL = "SERVER_URL"
private const val KEY_ASKED_NOTIFICATIONS = "ASKED_NOTIFICATIONS"
private const val REQ_CAMERA = 1 private const val REQ_CAMERA = 1
private const val REQ_STORAGE = 2 private const val REQ_STORAGE = 2
private const val REQ_NOTIFICATIONS = 3
private const val BRIDGE_NAME = "__mcNotifyBridge"
private const val SHIM_ASSET = "notification_shim.js"
private const val CHANNEL_ID = "mc_webui_activity"
private const val NOTIFICATION_ID = 1
private const val EXTRA_CLICKED_TAG = "clicked_tag"
} }
} }
@@ -0,0 +1,14 @@
<!--
Status bar icon. Android throws away the colour and keeps only the alpha
channel, so this has to be a flat white silhouette on transparent - a
launcher icon used here would come out as a plain white square.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M20,2H4C2.9,2 2,2.9 2,4v18l4,-4h14c1.1,0 2,-0.9 2,-2V4C22,2.9 21.1,2 20,2z" />
</vector>
@@ -24,4 +24,9 @@
<string name="download_failed">Download failed: %1$s</string> <string name="download_failed">Download failed: %1$s</string>
<string name="camera_denied">Camera permission denied - QR scanning needs it</string> <string name="camera_denied">Camera permission denied - QR scanning needs it</string>
<string name="storage_denied">Storage permission denied - the file cannot be saved</string> <string name="storage_denied">Storage permission denied - the file cannot be saved</string>
<!-- Notifications -->
<string name="notification_channel_name">Mesh activity</string>
<string name="notification_channel_description">New messages and pending contacts from your mc-webui server</string>
<string name="notifications_denied">Notification permission denied - mc-webui cannot alert you about new messages</string>
</resources> </resources>
+7 -7
View File
@@ -133,12 +133,11 @@ messages, contacts, the console, My Repeaters, the Path Analyzer, maps,
settings, themes. Links to other sites — a URL in a message, the packet settings, themes. Links to other sites — a URL in a message, the packet
analyzer — open in your normal browser, so the app stays on your instance. analyzer — open in your normal browser, so the app stays on your instance.
Two things depend on how your instance is reachable, and one is not available A couple of things depend on how your instance is reachable:
at all:
| Feature | In the app | | Feature | In the app |
|---|---| |---|---|
| **Notifications** for new messages | **Not available.** The app has no access to the browser's notification system. If you want notifications, also install mc-webui as a **PWA** in Chrome ([how](user-guide.md#installing-as-pwa)) — the app and the PWA can live side by side | | **Notifications** for new messages | **Work,** from version 1.1. Turn them on in the mc-webui menu as usual; Android asks for its own permission the first time. They arrive while the app is open or recently in the background — Android eventually suspends a backgrounded app, and notifications stop until you open it again. Unlike a browser, these also work over plain `http://`. Tapping one reopens the app |
| **Scanning a QR code** (Add Contact → Scan QR) | **Works on `https://` instances.** Android asks for camera permission the first time. Over plain `http://` the camera stays blocked — that is a browser rule, not an app limitation, and Chrome on the same phone behaves identically. Use **Paste URI** or **Manual entry** there | | **Scanning a QR code** (Add Contact → Scan QR) | **Works on `https://` instances.** Android asks for camera permission the first time. Over plain `http://` the camera stays blocked — that is a browser rule, not an app limitation, and Chrome on the same phone behaves identically. Use **Paste URI** or **Manual entry** there |
| **Downloading files** (e.g. database backups) | **Works.** Files land in the phone's **Downloads** folder, with the usual download notification | | **Downloading files** (e.g. database backups) | **Works.** Files land in the phone's **Downloads** folder, with the usual download notification |
| **HTTPS with a self-signed certificate** | **Refused,** with an "SSL error" message. Use a valid certificate (e.g. Let's Encrypt), or plain `http://` on the local network | | **HTTPS with a self-signed certificate** | **Refused,** with an "SSL error" message. Use a valid certificate (e.g. Let's Encrypt), or plain `http://` on the local network |
@@ -158,8 +157,9 @@ at all:
behind a reverse proxy with HTTPS and some form of access control — the app behind a reverse proxy with HTTPS and some form of access control — the app
adds no authentication of its own adds no authentication of its own
- **Permissions:** internet access; the camera, only when you use QR scanning - **Permissions:** internet access; the camera, only when you use QR scanning
and only after you allow it; and file storage on Android 9 and older, only and only after you allow it; notifications, only after you turn them on in
for saving a download. Nothing else — no contacts, no location, no background the menu and allow them; and file storage on Android 9 and older, only for
saving a download. Nothing else — no contacts, no location, no background
services services
- Every release is **signed with the same key** (certificate SHA-256 - Every release is **signed with the same key** (certificate SHA-256
`42:58:57:b3:60:0c:1b:89:2f:8d:3b:2a:5c:46:8b:fe:17:c0:d2:1f:6c:12:24:33:a4:e1:1b:51:be:9b:d2:30`), `42:58:57:b3:60:0c:1b:89:2f:8d:3b:2a:5c:46:8b:fe:17:c0:d2:1f:6c:12:24:33:a4:e1:1b:51:be:9b:d2:30`),
@@ -183,6 +183,6 @@ if you would rather not trust a prebuilt `.apk`.
## See also ## See also
- [User Guide](user-guide.md) — everything the interface itself can do - [User Guide](user-guide.md) — everything the interface itself can do
- [PWA Notifications](user-guide.md#pwa-notifications) — the browser-based - [PWA Notifications](user-guide.md#pwa-notifications) — how the same
alternative, with notification support notifications behave in the browser
- [Troubleshooting](troubleshooting.md) — when the server itself misbehaves - [Troubleshooting](troubleshooting.md) — when the server itself misbehaves
+3 -1
View File
@@ -10,7 +10,9 @@ For deep technical notes, see [architecture.md](architecture.md). For the full g
## Unreleased ## Unreleased
_Nothing yet since 2.3.0._ ### New features
- **The Android app can send notifications now.** When the app shipped in 2.3.0, notifications were the one thing it couldn't do, and the advice was to keep a Chrome "Add to Home Screen" install alongside it. That's no longer necessary — turn notifications on in the mc-webui menu as usual, allow the permission Android asks for, and new messages and pending contacts arrive as ordinary Android notifications. Tapping one reopens the app. Two things are actually better here than in the browser: they work over plain `http://` too (Chrome refuses notifications on an unencrypted page), and the app shows up in Android's notification settings like any other, so you can silence it or change its sound there. The same limit as the browser still applies: notifications arrive while the app is open or recently in the background, and stop once Android suspends it — reopening the app catches you up. Requires app version **1.1**; see the [Android App guide](https://github.com/MarekWo/mc-webui/blob/main/docs/android-app.md) for how to update.
--- ---