mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-08-04 16:03:07 +02:00
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:
+26
-1
@@ -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 —
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user