mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-10-30 23:51:57 +01:00
feat(android): background notifications and remembered servers
Two changes to the Android wrapper, shipping together as app version 2.1 (versionCode 6). Notifications stopped arriving a few minutes after the app was backgrounded, and no setting on the phone could prevent it: Android freezes a process with nothing in the foreground, and a frozen process runs no JavaScript and holds no socket. MeshWatchService is a foreground service of type remoteMessaging that does no work of its own - it exists so Android leaves the process alone. Every notification is still built by the mc-webui page, so mutes, blocked senders, channel names, translations and deep links stay in one place rather than being duplicated in Kotlin. The service follows mc-webui's own notification toggle, read from localStorage by the shim rather than asked of the page, because the wrapper connects to whichever server its user typed in - very often an older mc-webui. The shim also wraps Storage.prototype.setItem, since localStorage fires no event in the document that wrote it. Android's own switches have the last word, so a revoked permission or a silenced channel stops the service too. setRendererPriorityPolicy goes with it: the page renders in a process of its own, and WebView waives that process's priority once the view stops being visible, so a service saving only the host process would still lose the renderer under memory pressure. Separately, the address screen now lists the servers reached before, so running more than one instance no longer means retyping the whole address to switch. Only addresses that answered are listed, which keeps typos out. Also replaces the deprecated setDecorFitsSystemWindows with enableEdgeToEdge, which is what Play Console has been flagging twice. Its default navigation-bar scrim is overridden, or it would paint over the strip the wrapper colours to match the page below Android 10. Docs, the privacy policy (both languages) and the app-vs-PWA comparison are updated: three of them said the app ran nothing in the background. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2afa9c182b
commit
fefa43d30d
13 files changed
+625
-24
No files matched your search
+22
-5
@@ -27,7 +27,9 @@ src/
|
||||
└── src/main/
|
||||
├── 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
|
||||
│ └── MeshWatchService.kt ← background delivery
|
||||
└── res/ layout, strings, icons
|
||||
```
|
||||
|
||||
@@ -61,10 +63,25 @@ out as "Unavailable". Native code has to supply the missing piece:
|
||||
(`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
|
||||
- **Background delivery** (from `versionCode 6`) is `MeshWatchService`, a
|
||||
foreground service of type `remoteMessaging`. It does no work: it exists so
|
||||
Android leaves the process out of the cached-process freezer, which is what
|
||||
used to silence alerts minutes after backgrounding. The page keeps producing
|
||||
every notification, so mutes, blocked senders, channel names, translations and
|
||||
deep links stay in one place
|
||||
- The service follows mc-webui's own notification toggle. The shim reads
|
||||
`mc_notifications_enabled` from `localStorage` and reports it over the bridge —
|
||||
read rather than asked of the page, because the wrapper connects to whichever
|
||||
server its user typed in, very often an older mc-webui. It also wraps
|
||||
`Storage.prototype.setItem`, since localStorage fires no event in the document
|
||||
that wrote it
|
||||
- `setRendererPriorityPolicy(RENDERER_PRIORITY_IMPORTANT, false)` goes with it.
|
||||
The page renders in a process of its own, and WebView waives that process's
|
||||
priority once the view stops being visible — a service that saves only the
|
||||
host process would still lose the renderer under memory pressure
|
||||
- Android 12 forbids starting a foreground service from the background, so the
|
||||
service is only ever started while the activity is on screen: from the shim's
|
||||
report and again from `onResume`
|
||||
|
||||
## Building
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ android {
|
||||
minSdk = 21
|
||||
// Google Play rejects anything below 36 submitted after 2026-08-31
|
||||
targetSdk = 36
|
||||
versionCode = 5
|
||||
versionName = "2.0"
|
||||
versionCode = 6
|
||||
versionName = "2.1"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
|
||||
@@ -7,6 +7,11 @@
|
||||
<!-- 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" />
|
||||
<!-- Keeps the process alive while the app is in the background, so the page
|
||||
behind the WebView can still raise those alerts. Only ever started when
|
||||
notifications are switched on in mc-webui itself -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING" />
|
||||
<!-- Saving downloads (e.g. database backups) before Android 10 -->
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
@@ -35,6 +40,13 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- No exported entry point: the activity is the only thing that starts
|
||||
it, and only while the page asks for notifications -->
|
||||
<service
|
||||
android:name=".MeshWatchService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="remoteMessaging" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -121,4 +121,51 @@
|
||||
McNotification.maxActions = 0;
|
||||
|
||||
window.Notification = McNotification;
|
||||
|
||||
/*
|
||||
* Whether the user actually wants alerts, reported to the wrapper so it can
|
||||
* decide whether to hold this page open in the background.
|
||||
*
|
||||
* mc-webui keeps that answer in localStorage, and it is read from there
|
||||
* rather than asked of the page, because the wrapper connects to whichever
|
||||
* server its user typed in - very often one running an older mc-webui that
|
||||
* has never heard of any of this. Reading the flag works on all of them.
|
||||
*/
|
||||
var PREF = 'mc_notifications_enabled';
|
||||
|
||||
function reportPreference(changed) {
|
||||
var enabled = false;
|
||||
try {
|
||||
enabled = localStorage.getItem(PREF) === 'true';
|
||||
} catch (e) {
|
||||
// Storage can be blocked outright; treat that as "no alerts"
|
||||
}
|
||||
try {
|
||||
bridge.setNotificationsEnabled(enabled, !!changed);
|
||||
} catch (e) {
|
||||
// An older wrapper without this method: alerts still work, they
|
||||
// just stop once Android freezes the app, exactly as before
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* localStorage fires no event in the document that wrote it, so the only
|
||||
* way to hear the toggle flip is to watch the write itself. `changed` marks
|
||||
* these apart from the report on load: a deliberate switch-on is what lifts
|
||||
* a Stop tapped on the wrapper's own notification.
|
||||
*/
|
||||
var nativeSetItem = Storage.prototype.setItem;
|
||||
var nativeRemoveItem = Storage.prototype.removeItem;
|
||||
|
||||
Storage.prototype.setItem = function (key, value) {
|
||||
nativeSetItem.apply(this, arguments);
|
||||
if (key === PREF && this === window.localStorage) reportPreference(true);
|
||||
};
|
||||
|
||||
Storage.prototype.removeItem = function (key) {
|
||||
nativeRemoveItem.apply(this, arguments);
|
||||
if (key === PREF && this === window.localStorage) reportPreference(true);
|
||||
};
|
||||
|
||||
reportPreference(false);
|
||||
})();
|
||||
@@ -33,10 +33,13 @@ import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import android.widget.Button
|
||||
import android.widget.EditText
|
||||
import android.widget.ImageButton
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.SystemBarStyle
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.app.ActivityCompat
|
||||
@@ -47,6 +50,7 @@ import androidx.core.graphics.ColorUtils
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.net.URI
|
||||
|
||||
@@ -61,10 +65,12 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var prefs: SharedPreferences
|
||||
private lateinit var rootLayout: View
|
||||
private lateinit var configLayout: LinearLayout
|
||||
private lateinit var configLayout: View
|
||||
private lateinit var configMessage: TextView
|
||||
private lateinit var webView: WebView
|
||||
private lateinit var urlInput: EditText
|
||||
private lateinit var historyLabel: TextView
|
||||
private lateinit var historyList: LinearLayout
|
||||
|
||||
/** Camera request from the page (QR scanning), waiting for the Android permission. */
|
||||
private var pendingCameraRequest: PermissionRequest? = null
|
||||
@@ -82,8 +88,21 @@ class MainActivity : AppCompatActivity() {
|
||||
/** The notification shim, injected into every page as it starts loading. */
|
||||
private var pageStartScript: String? = null
|
||||
|
||||
/** mc-webui's own notification switch, as last reported by the shim. */
|
||||
private var notificationsWanted = false
|
||||
|
||||
/** Whether the page now loading has already failed, for [rememberServer]. */
|
||||
private var pageLoadFailed = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
// Before setContentView, as androidx asks. Transparent rather than the
|
||||
// default, which paints a light scrim over the navigation bar below
|
||||
// Android 10 - on top of the strip this class colours to match the
|
||||
// page. Icon contrast is handled in [applyChromeColor], so the style's
|
||||
// own choice does not survive the first colour the page reports.
|
||||
val bars = SystemBarStyle.dark(Color.TRANSPARENT)
|
||||
enableEdgeToEdge(statusBarStyle = bars, navigationBarStyle = bars)
|
||||
setContentView(R.layout.activity_main)
|
||||
|
||||
prefs = getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
@@ -92,6 +111,8 @@ class MainActivity : AppCompatActivity() {
|
||||
configMessage = findViewById(R.id.configMessage)
|
||||
webView = findViewById(R.id.webView)
|
||||
urlInput = findViewById(R.id.urlInput)
|
||||
historyLabel = findViewById(R.id.historyLabel)
|
||||
historyList = findViewById(R.id.historyList)
|
||||
|
||||
setUpEdgeToEdge()
|
||||
createNotificationChannel()
|
||||
@@ -114,7 +135,13 @@ class MainActivity : AppCompatActivity() {
|
||||
override fun handleOnBackPressed() = goBack()
|
||||
})
|
||||
|
||||
// A Stop tapped on the background notice holds only until the app is
|
||||
// opened again, and this is that moment
|
||||
MeshWatchService.stoppedByUser = false
|
||||
|
||||
val savedUrl = prefs.getString(KEY_URL, null)
|
||||
// An address saved before this list existed still belongs in it
|
||||
if (!savedUrl.isNullOrEmpty() && serverHistory().isEmpty()) rememberServer(savedUrl)
|
||||
// Started by a notification tap: open the conversation it named rather
|
||||
// than wherever the app happened to be left
|
||||
val tapped = intent.getStringExtra(EXTRA_CLICKED_URL)?.let { resolveTarget(it) }
|
||||
@@ -134,6 +161,18 @@ class MainActivity : AppCompatActivity() {
|
||||
callJs("window.__mcAppResumed")
|
||||
// The user may have been away switching the mc-webui theme
|
||||
matchSystemBarsToPage()
|
||||
// Being on screen is the only state Android allows a foreground service
|
||||
// to be started from, so take every visit as a chance to put it back
|
||||
updateWatchService()
|
||||
}
|
||||
|
||||
/**
|
||||
* The service exists only to keep this activity's WebView running, so it
|
||||
* has nothing left to do once the activity is gone.
|
||||
*/
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
MeshWatchService.stop(this)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -199,6 +238,10 @@ class MainActivity : AppCompatActivity() {
|
||||
configMessage.visibility = if (message == null) View.GONE else View.VISIBLE
|
||||
configLayout.visibility = View.VISIBLE
|
||||
webView.visibility = View.GONE
|
||||
renderServerHistory()
|
||||
// No page behind the form, so nothing left worth holding open
|
||||
notificationsWanted = false
|
||||
updateWatchService()
|
||||
// The form is white whatever the theme of the page we just left was
|
||||
applyChromeColor(Color.WHITE)
|
||||
}
|
||||
@@ -228,6 +271,79 @@ class MainActivity : AppCompatActivity() {
|
||||
.show()
|
||||
}
|
||||
|
||||
// -------------------------------------------------------- server history
|
||||
|
||||
/**
|
||||
* Addresses that answered before, newest first. Anyone running more than
|
||||
* one instance - their own and the public demo, say - was retyping the
|
||||
* whole address every time they wanted the other one.
|
||||
*/
|
||||
private fun serverHistory(): MutableList<String> {
|
||||
val stored = prefs.getString(KEY_HISTORY, null) ?: return mutableListOf()
|
||||
return try {
|
||||
val entries = JSONArray(stored)
|
||||
val list = mutableListOf<String>()
|
||||
for (i in 0 until entries.length()) {
|
||||
val entry = entries.optString(i, "")
|
||||
if (entry.isNotEmpty()) list.add(entry)
|
||||
}
|
||||
list
|
||||
} catch (e: Exception) {
|
||||
// Nothing here is worth losing a launch over
|
||||
mutableListOf()
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveServerHistory(list: List<String>) {
|
||||
prefs.edit().putString(KEY_HISTORY, JSONArray(list).toString()).apply()
|
||||
}
|
||||
|
||||
/** Moves an address to the front, keeping the newest [HISTORY_LIMIT] of them. */
|
||||
private fun rememberServer(url: String?) {
|
||||
if (url.isNullOrEmpty()) return
|
||||
val list = serverHistory()
|
||||
val alreadyFirst = list.firstOrNull()?.equals(url, ignoreCase = true) == true
|
||||
if (alreadyFirst) return
|
||||
list.removeAll { it.equals(url, ignoreCase = true) }
|
||||
list.add(0, url)
|
||||
while (list.size > HISTORY_LIMIT) list.removeAt(list.size - 1)
|
||||
saveServerHistory(list)
|
||||
}
|
||||
|
||||
/**
|
||||
* Forgetting an address only removes it from this list. The saved server,
|
||||
* and any username and password kept for it, are left alone - forgetting
|
||||
* the one you are connected to should not sign you out of it.
|
||||
*/
|
||||
private fun forgetServer(url: String) {
|
||||
val list = serverHistory()
|
||||
list.removeAll { it.equals(url, ignoreCase = true) }
|
||||
saveServerHistory(list)
|
||||
renderServerHistory()
|
||||
}
|
||||
|
||||
private fun renderServerHistory() {
|
||||
val list = serverHistory()
|
||||
historyList.removeAllViews()
|
||||
val visibility = if (list.isEmpty()) View.GONE else View.VISIBLE
|
||||
historyLabel.visibility = visibility
|
||||
historyList.visibility = visibility
|
||||
list.forEach { url ->
|
||||
val row = layoutInflater.inflate(R.layout.item_server, historyList, false)
|
||||
row.findViewById<TextView>(R.id.serverAddress).text = url
|
||||
row.setOnClickListener { useServer(url) }
|
||||
row.findViewById<ImageButton>(R.id.serverForget).setOnClickListener { forgetServer(url) }
|
||||
historyList.addView(row)
|
||||
}
|
||||
}
|
||||
|
||||
/** Connects to a remembered address, exactly as typing it in would. */
|
||||
private fun useServer(url: String) {
|
||||
prefs.edit().putString(KEY_URL, url).apply()
|
||||
urlInput.setText(url)
|
||||
connect(url)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ edge to edge
|
||||
|
||||
/**
|
||||
@@ -241,8 +357,6 @@ class MainActivity : AppCompatActivity() {
|
||||
* field visible while it is being typed into.
|
||||
*/
|
||||
private fun setUpEdgeToEdge() {
|
||||
@Suppress("DEPRECATION")
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
ViewCompat.setOnApplyWindowInsetsListener(rootLayout) { view, insets ->
|
||||
val safe = insets.getInsets(
|
||||
WindowInsetsCompat.Type.systemBars() or
|
||||
@@ -312,6 +426,14 @@ class MainActivity : AppCompatActivity() {
|
||||
textZoom = 100
|
||||
}
|
||||
|
||||
// The service keeps this process out of the freezer, but the page runs
|
||||
// in a process of its own, and WebView drops that one's priority the
|
||||
// moment the view stops being visible. Say otherwise, or the renderer
|
||||
// is evicted under memory pressure and takes the alerts with it.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
webView.setRendererPriorityPolicy(WebView.RENDERER_PRIORITY_IMPORTANT, false)
|
||||
}
|
||||
|
||||
installNotificationShim()
|
||||
|
||||
webView.webViewClient = object : WebViewClient() {
|
||||
@@ -320,6 +442,7 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||
super.onPageStarted(view, url, favicon)
|
||||
pageLoadFailed = false
|
||||
// Puts window.Notification in place before the page looks for it
|
||||
pageStartScript?.let { view?.evaluateJavascript(it, null) }
|
||||
}
|
||||
@@ -327,6 +450,9 @@ class MainActivity : AppCompatActivity() {
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
super.onPageFinished(view, url)
|
||||
watchPageBackground()
|
||||
// Only an address that actually answered is worth offering
|
||||
// again, which is what keeps typos out of the list
|
||||
if (!pageLoadFailed) rememberServer(prefs.getString(KEY_URL, null))
|
||||
}
|
||||
|
||||
// Deprecated, but it is the only one Android 5.x calls
|
||||
@@ -335,11 +461,14 @@ class MainActivity : AppCompatActivity() {
|
||||
handleUrl(if (url == null) null else Uri.parse(url))
|
||||
|
||||
override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) {
|
||||
if (request?.isForMainFrame == true) showConfig(getString(R.string.error_unreachable))
|
||||
if (request?.isForMainFrame != true) return
|
||||
pageLoadFailed = true
|
||||
showConfig(getString(R.string.error_unreachable))
|
||||
}
|
||||
|
||||
override fun onReceivedSslError(view: WebView?, handler: SslErrorHandler?, error: SslError?) {
|
||||
handler?.cancel()
|
||||
pageLoadFailed = true
|
||||
showConfig(getString(R.string.error_ssl))
|
||||
}
|
||||
|
||||
@@ -569,6 +698,35 @@ class MainActivity : AppCompatActivity() {
|
||||
fun close(tag: String) = runOnUiThread {
|
||||
NotificationManagerCompat.from(this@MainActivity).cancel(tag, NOTIFICATION_ID)
|
||||
}
|
||||
|
||||
/**
|
||||
* mc-webui's own notification switch, reported by the shim on every
|
||||
* page load and again whenever it is flipped. It decides whether this
|
||||
* app is worth keeping alive in the background at all.
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun setNotificationsEnabled(enabled: Boolean, changed: Boolean) = runOnUiThread {
|
||||
// Turning alerts on by hand is a clear enough answer to lift a Stop
|
||||
// tapped earlier; a page merely reloading is not
|
||||
if (enabled && changed) MeshWatchService.stoppedByUser = false
|
||||
notificationsWanted = enabled
|
||||
updateWatchService()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Brings the background service in line with what the page asked for. Safe
|
||||
* to call from anywhere and as often as convenient: both directions are
|
||||
* no-ops once the service is already in the state being asked for.
|
||||
*/
|
||||
private fun updateWatchService() {
|
||||
// Android's own switches have the last word: a revoked permission or a
|
||||
// channel turned off there leaves nothing for the service to show, and
|
||||
// holding a process open to raise notifications nobody will see is the
|
||||
// one thing worse than not raising them
|
||||
val allowed = notificationsWanted &&
|
||||
NotificationManagerCompat.from(this).areNotificationsEnabled()
|
||||
if (allowed) MeshWatchService.start(this) else MeshWatchService.stop(this)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -704,6 +862,7 @@ class MainActivity : AppCompatActivity() {
|
||||
companion object {
|
||||
private const val PREFS = "MC_PREFS"
|
||||
private const val KEY_URL = "SERVER_URL"
|
||||
private const val KEY_HISTORY = "SERVER_HISTORY"
|
||||
private const val KEY_ASKED_NOTIFICATIONS = "ASKED_NOTIFICATIONS"
|
||||
// Suffixed with the host, so each server keeps its own login
|
||||
private const val KEY_AUTH_USER = "AUTH_USER_"
|
||||
@@ -712,6 +871,10 @@ class MainActivity : AppCompatActivity() {
|
||||
private const val REQ_STORAGE = 2
|
||||
private const val REQ_NOTIFICATIONS = 3
|
||||
|
||||
// Enough to cover a home server, a demo and a couple of experiments,
|
||||
// without the form turning into a page of its own
|
||||
private const val HISTORY_LIMIT = 6
|
||||
|
||||
private const val BRIDGE_NAME = "__mcNotifyBridge"
|
||||
private const val CHROME_BRIDGE_NAME = "__mcChromeBridge"
|
||||
private const val SHIM_ASSET = "notification_shim.js"
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
package it.wojtaszek.mc.wrapper
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
/**
|
||||
* Keeps mc-webui reachable while the user is somewhere else on the phone.
|
||||
*
|
||||
* Android puts a backgrounded app's process into the cached-process freezer
|
||||
* within minutes, and a frozen process runs no JavaScript and holds no socket -
|
||||
* which is why alerts used to stop arriving shortly after leaving the app. No
|
||||
* battery setting fixes that: "Unrestricted" exempts an app from Doze and App
|
||||
* Standby, not from the freezer, which is memory management. A foreground
|
||||
* service is the one thing Android accepts as a reason to leave a process alone.
|
||||
*
|
||||
* This service therefore does nothing at all by itself. Every notification is
|
||||
* still built by mc-webui's own JavaScript, exactly as it is in a browser, and
|
||||
* so keeps the page's mute rules, blocked senders, channel names, translations
|
||||
* and deep links without any of it being duplicated here. All the service
|
||||
* contributes is a reason for that JavaScript to still be running.
|
||||
*
|
||||
* It exists only while the page wants notifications, so anyone who never turns
|
||||
* them on never sees the permanent notice that Android requires in return.
|
||||
*/
|
||||
class MeshWatchService : Service() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createChannel()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent?.action == ACTION_STOP) {
|
||||
// "Not now" rather than "never": the flag is deliberately not saved
|
||||
// anywhere, so simply opening the app again brings alerts back
|
||||
stoppedByUser = true
|
||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
// The type has to match what the manifest declares, and remoteMessaging
|
||||
// only exists from Android 14. Older versions get an untyped foreground
|
||||
// service, which they are perfectly happy with
|
||||
val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_REMOTE_MESSAGING
|
||||
} else {
|
||||
0
|
||||
}
|
||||
ServiceCompat.startForeground(this, NOTIFICATION_ID, buildNotification(), type)
|
||||
|
||||
// Nothing to resume: without the activity and its WebView there is no
|
||||
// page to keep alive, so a restart on its own would keep an empty
|
||||
// process running behind a notification that promises something
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
/**
|
||||
* The user swiped the app away. The WebView went with it, so there is no
|
||||
* longer anything to hold open - and a notice saying otherwise would be a
|
||||
* lie that only Force stop could clear.
|
||||
*/
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
super.onTaskRemoved(rootIntent)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
/**
|
||||
* A channel of its own, at low importance, so this permanent notice stays
|
||||
* silent and collapsed - and can be turned off on its own, without taking
|
||||
* the message alerts down with it.
|
||||
*/
|
||||
private fun createChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
getString(R.string.background_channel_name),
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply {
|
||||
description = getString(R.string.background_channel_description)
|
||||
setShowBadge(false)
|
||||
}
|
||||
(getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
|
||||
.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
private fun buildNotification(): Notification {
|
||||
var flags = PendingIntent.FLAG_UPDATE_CURRENT
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) flags = flags or PendingIntent.FLAG_IMMUTABLE
|
||||
|
||||
val open = PendingIntent.getActivity(
|
||||
this,
|
||||
REQ_OPEN,
|
||||
Intent(this, MainActivity::class.java).apply {
|
||||
this.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
},
|
||||
flags
|
||||
)
|
||||
val stop = PendingIntent.getService(
|
||||
this,
|
||||
REQ_STOP,
|
||||
Intent(this, MeshWatchService::class.java).setAction(ACTION_STOP),
|
||||
flags
|
||||
)
|
||||
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(getString(R.string.background_title))
|
||||
.setContentText(getString(R.string.background_text))
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(getString(R.string.background_text)))
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
.setOngoing(true)
|
||||
.setSilent(true)
|
||||
.setShowWhen(false)
|
||||
.setContentIntent(open)
|
||||
.addAction(0, getString(R.string.background_stop), stop)
|
||||
.build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL_ID = "mc_webui_background"
|
||||
private const val NOTIFICATION_ID = 2
|
||||
private const val REQ_OPEN = 10
|
||||
private const val REQ_STOP = 11
|
||||
private const val ACTION_STOP = "it.wojtaszek.mc.wrapper.STOP_WATCHING"
|
||||
|
||||
/**
|
||||
* Set by the Stop action and cleared when the activity is created
|
||||
* again, so the refusal lasts exactly as long as this run of the app.
|
||||
* Nothing is written to disk: a permanent switch hidden in a
|
||||
* notification is how a messaging app goes quiet for months without
|
||||
* anyone working out why.
|
||||
*/
|
||||
@Volatile
|
||||
var stoppedByUser = false
|
||||
|
||||
fun start(context: Context) {
|
||||
if (stoppedByUser) return
|
||||
try {
|
||||
ContextCompat.startForegroundService(
|
||||
context, Intent(context, MeshWatchService::class.java)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
// From Android 12 a foreground service cannot be started from
|
||||
// the background. The activity asks again whenever it is on
|
||||
// screen, which is the only state that start is allowed from
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
context.stopService(Intent(context, MeshWatchService::class.java))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,16 +6,24 @@
|
||||
android:layout_height="match_parent"
|
||||
android:background="#ffffff">
|
||||
|
||||
<!-- Server address form -->
|
||||
<LinearLayout
|
||||
<!-- Server address form. It scrolls because the list of remembered servers
|
||||
grows, and because the keyboard eats half the screen while the address
|
||||
is being typed - without this the button goes out of reach. -->
|
||||
<ScrollView
|
||||
android:id="@+id/configLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="32dp"
|
||||
android:layout_height="0dp"
|
||||
android:fillViewport="true"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="vertical"
|
||||
android:padding="32dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
@@ -62,7 +70,29 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/save_and_connect"/>
|
||||
|
||||
<!-- Servers reached before. Both this and the list below are hidden
|
||||
until there is something to show, so a first run looks exactly
|
||||
as it always did. -->
|
||||
<TextView
|
||||
android:id="@+id/historyLabel"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="28dp"
|
||||
android:text="@string/recent_servers"
|
||||
android:textColor="#666666"
|
||||
android:textSize="12sp"
|
||||
android:visibility="gone"/>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/historyList"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:orientation="vertical"
|
||||
android:visibility="gone"/>
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
|
||||
<!-- mc-webui itself -->
|
||||
<WebView
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- One remembered server address: tap the row to connect, tap the cross to
|
||||
forget it. Sized for a finger rather than for the text it holds. -->
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:gravity="center_vertical"
|
||||
android:minHeight="48dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<!-- Long addresses lose their middle, which keeps both the host and the
|
||||
port readable - the two halves that tell servers apart -->
|
||||
<TextView
|
||||
android:id="@+id/serverAddress"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:ellipsize="middle"
|
||||
android:paddingStart="4dp"
|
||||
android:paddingEnd="8dp"
|
||||
android:singleLine="true"
|
||||
android:textColor="#1565C0"
|
||||
android:textSize="15sp" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/serverForget"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/forget_server"
|
||||
android:src="@android:drawable/ic_menu_close_clear_cancel" />
|
||||
|
||||
</LinearLayout>
|
||||
@@ -8,6 +8,10 @@
|
||||
<string name="save_and_connect">SAVE & CONNECT</string>
|
||||
<string name="address_empty">Address cannot be empty</string>
|
||||
|
||||
<!-- Servers connected to before, for switching back without retyping -->
|
||||
<string name="recent_servers">Recent servers</string>
|
||||
<string name="forget_server">Forget this server</string>
|
||||
|
||||
<!-- Leaving the app -->
|
||||
<string name="leave_prompt">Leave mc-webui, or connect to a different server?</string>
|
||||
<string name="exit">Exit</string>
|
||||
@@ -37,4 +41,12 @@
|
||||
<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>
|
||||
|
||||
<!-- Background delivery. Android insists on a permanent notice in return
|
||||
for leaving the app running, so it may as well say what it is for -->
|
||||
<string name="background_channel_name">Background delivery</string>
|
||||
<string name="background_channel_description">The silent, permanent notice shown while mc-webui keeps listening for new messages in the background</string>
|
||||
<string name="background_title">Listening for new messages</string>
|
||||
<string name="background_text">mc-webui stays connected while you are in another app, so new messages still reach you.</string>
|
||||
<string name="background_stop">Stop</string>
|
||||
</resources>
|
||||
+56
-6
@@ -154,6 +154,20 @@ wrong address, server down, phone off the network. Either way the form opens
|
||||
**pre-filled with the address you are using**, and nothing is overwritten until
|
||||
you tap **SAVE & CONNECT**: a dropped connection never costs you the address.
|
||||
|
||||
### Switching between several servers
|
||||
|
||||
From version 2.1 the form also lists the servers you have reached before, under
|
||||
**Recent servers**. Tap one and the app connects to it straight away — no
|
||||
retyping, and any username and password saved for that server still apply, since
|
||||
those are remembered per address. The **✕** beside an entry drops it from the
|
||||
list; that only forgets the shortcut, never the server you are currently using
|
||||
or its saved login.
|
||||
|
||||
The list holds the six most recent addresses, newest first, and only ones that
|
||||
actually answered — a mistyped address never joins it. If you run a single
|
||||
instance you will simply see it listed there on its own, and nothing about the
|
||||
form changes.
|
||||
|
||||
### Servers that ask for a password
|
||||
|
||||
If your instance sits behind a reverse proxy configured to require a login
|
||||
@@ -193,11 +207,42 @@ A couple of things depend on how your instance is reachable:
|
||||
|
||||
| Feature | In the app |
|
||||
|---|---|
|
||||
| **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 |
|
||||
| **Notifications** for new messages | **Work,** from version 1.1, and **keep working in the background from version 2.1** (see below). Turn them on in the mc-webui menu as usual; Android asks for its own permission the first time. Unlike a browser, these also work over plain `http://`. Tapping one opens the conversation it came from |
|
||||
| **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 |
|
||||
| **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 |
|
||||
|
||||
### Notifications while the app is in the background
|
||||
|
||||
Up to version 2.0 alerts went quiet a few minutes after you switched away from
|
||||
the app. That was Android, not a setting anyone had got wrong: an app with
|
||||
nothing running in the foreground is eventually **frozen** to save memory, and a
|
||||
frozen app holds no connection to your server and runs no code to alert you
|
||||
with. No battery option prevents that — "Unrestricted" exempts an app from Doze
|
||||
and battery optimisation, which are a different mechanism entirely.
|
||||
|
||||
From version 2.1 the app asks Android to leave it running, which Android grants
|
||||
in exchange for a **permanent notice in the shade** saying it is doing so:
|
||||
|
||||
> **Listening for new messages** — mc-webui stays connected while you are in
|
||||
> another app, so new messages still reach you.
|
||||
|
||||
That notice is silent, sits at the bottom of the shade, and only appears **once
|
||||
you turn notifications on in mc-webui itself**. Turn them off there and it goes
|
||||
away with them. Its **Stop** button ends it for the rest of the session, without
|
||||
changing any setting — opening the app again brings it back.
|
||||
|
||||
It can also be silenced on its own, in **Android Settings → Apps → mc-webui →
|
||||
Notifications**, where it appears as the **Background delivery** category
|
||||
separate from **Mesh activity**. Turning that category off stops the notice from
|
||||
showing, and Android then stops keeping the app alive too — so message alerts go
|
||||
back to arriving only while the app is open.
|
||||
|
||||
Nothing about this sends anything anywhere: the connection being held open is
|
||||
the one to your own server, and the alerts are still built by the mc-webui page
|
||||
itself, which is why mutes, blocked senders and channel names all behave exactly
|
||||
as they do in a browser tab.
|
||||
|
||||
---
|
||||
|
||||
## Security notes
|
||||
@@ -218,9 +263,12 @@ A couple of things depend on how your instance is reachable:
|
||||
credentials are encrypted in transit like everything else
|
||||
- **Permissions:** internet access; the camera, only when you use QR scanning
|
||||
and only after you allow it; notifications, only after you turn them on in
|
||||
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
|
||||
the menu and allow them; from version 2.1 a **foreground service**, started
|
||||
only while those notifications are on, so they keep arriving in the background
|
||||
(it shows a permanent notice for as long as it runs, and holds open the
|
||||
connection to your own server — nothing else); and file storage on Android 9
|
||||
and older, only for saving a download. Nothing else — no contacts, no
|
||||
location, no analytics
|
||||
- Every release is **signed with the same key** — the Play build included, because
|
||||
the key was registered with Play App Signing rather than letting Google
|
||||
generate its own (certificate SHA-256
|
||||
@@ -233,8 +281,10 @@ A couple of things depend on how your instance is reachable:
|
||||
|
||||
A minimal Android WebView wrapper: one screen for the server address, one
|
||||
full-screen WebView for mc-webui itself, and a saved preference between them.
|
||||
No analytics, no third-party services, no background activity — when the app is
|
||||
closed, nothing of it runs.
|
||||
No analytics and no third-party services. The one thing that runs outside the
|
||||
screen is the foreground service described above, which does no work of its own
|
||||
— it exists purely so Android leaves the page running — and only exists while
|
||||
notifications are switched on. Close the app and nothing of it runs.
|
||||
|
||||
The complete source is in [`android/src/`](../android/src), and
|
||||
[`android/README.md`](../android/README.md) describes how to build it yourself
|
||||
|
||||
@@ -144,7 +144,7 @@
|
||||
|
||||
<!-- ===================================================== ENGLISH ========== -->
|
||||
<h1 id="english">Privacy Policy — mc-webui for Android</h1>
|
||||
<p class="updated">Last updated: 19 August 2026 · Applies to the app <code>it.wojtaszek.mc.wrapper</code></p>
|
||||
<p class="updated">Last updated: 6 September 2026 · Applies to the app <code>it.wojtaszek.mc.wrapper</code></p>
|
||||
|
||||
<div class="note">
|
||||
<p><strong>In short:</strong> this app collects no personal data. It contains no
|
||||
@@ -189,6 +189,12 @@ a standard, non-rooted Android device is readable only by this app:</p>
|
||||
<td>So the app can sign in again on the next launch instead of prompting
|
||||
every time</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>The addresses of servers you have connected to before</td>
|
||||
<td>So you can switch back to one without typing it again. Kept only on
|
||||
the phone, at most six of them, and any of them can be removed from
|
||||
the address screen</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>A flag recording whether the notification permission has already been
|
||||
requested</td>
|
||||
@@ -239,6 +245,15 @@ what happens on it.</p>
|
||||
own server reports. There is no push service and no third party
|
||||
involved.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>FOREGROUND_SERVICE</code><br><code>FOREGROUND_SERVICE_REMOTE_MESSAGING</code></td>
|
||||
<td>Keeping the app running while it is in the background, so that alerts
|
||||
about new messages still reach you rather than stopping a few minutes
|
||||
after you switch away. The service starts only once you turn
|
||||
notifications on in mc-webui itself, shows a permanent notice while it
|
||||
runs, and can be stopped from that notice. It sends nothing anywhere:
|
||||
the connection it holds open is the one to your own server.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>WRITE_EXTERNAL_STORAGE</code><br>(Android 9 and older only)</td>
|
||||
<td>Saving files you choose to download from your own server, such as a
|
||||
@@ -283,7 +298,7 @@ raised at
|
||||
<!-- ====================================================== POLSKI ========== -->
|
||||
<h1 id="polski" lang="pl">Polityka prywatności — mc-webui dla Androida</h1>
|
||||
<div lang="pl">
|
||||
<p class="updated">Ostatnia aktualizacja: 19 sierpnia 2026 · Dotyczy aplikacji <code>it.wojtaszek.mc.wrapper</code></p>
|
||||
<p class="updated">Ostatnia aktualizacja: 6 września 2026 · Dotyczy aplikacji <code>it.wojtaszek.mc.wrapper</code></p>
|
||||
|
||||
<div class="note">
|
||||
<p><strong>W skrócie:</strong> ta aplikacja nie zbiera żadnych danych
|
||||
@@ -331,6 +346,12 @@ dla niej:</p>
|
||||
<td>Żeby aplikacja mogła zalogować się ponownie przy kolejnym
|
||||
uruchomieniu, zamiast pytać za każdym razem</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Adresy serwerów, z którymi łączyłeś się wcześniej</td>
|
||||
<td>Żebyś mógł wrócić do jednego z nich bez wpisywania adresu od nowa.
|
||||
Przechowywane wyłącznie na telefonie, najwyżej sześć, i każdy z nich
|
||||
można usunąć z ekranu adresu</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Znacznik, czy pytano już o zgodę na powiadomienia</td>
|
||||
<td>Żeby odróżnić „jeszcze nie pytano" od „odmówiono" i nie pytać
|
||||
@@ -382,6 +403,15 @@ na nim dzieje.</p>
|
||||
na podstawie tego, co zgłasza Twój własny serwer. Nie ma tu żadnej
|
||||
usługi push ani podmiotu trzeciego.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>FOREGROUND_SERVICE</code><br><code>FOREGROUND_SERVICE_REMOTE_MESSAGING</code></td>
|
||||
<td>Utrzymywanie aplikacji przy życiu, gdy jest w tle, żeby powiadomienia
|
||||
o nowych wiadomościach nadal do Ciebie docierały, a nie milkły kilka
|
||||
minut po przełączeniu się na coś innego. Usługa uruchamia się dopiero
|
||||
po włączeniu powiadomień w samym mc-webui, przez cały czas działania
|
||||
pokazuje stałe powiadomienie i można ją z niego zatrzymać. Niczego
|
||||
nigdzie nie wysyła: podtrzymuje połączenie z Twoim własnym serwerem.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>WRITE_EXTERNAL_STORAGE</code><br>(tylko Android 9 i starszy)</td>
|
||||
<td>Zapisywanie plików, które sam pobierasz z własnego serwera, na
|
||||
|
||||
@@ -1141,6 +1141,10 @@ To get the full PWA experience with app badge counters:
|
||||
- This is normal OS behavior for battery saving
|
||||
- Reopen app to resume notifications
|
||||
- Full "wake device" support would require Web Push API (not implemented)
|
||||
- **The [Android app](android-app.md) does not have this limitation** from
|
||||
version 2.1: it runs a foreground service that keeps notifications arriving
|
||||
while it is in the background. The PWA cannot do the same — only an installed
|
||||
app can ask Android for that
|
||||
|
||||
---
|
||||
|
||||
@@ -1170,6 +1174,7 @@ without uninstalling and without losing the address you typed. The
|
||||
| Full screen, own icon | Yes | Yes |
|
||||
| Setup | Install from Google Play (or the `.apk`), type the address once | Open in Chrome, add to home screen |
|
||||
| Notifications | Yes, from app version 1.1 — and over plain `http://` too | Yes (see above), on `https://` only |
|
||||
| Notifications **while in the background** | Yes, from app version 2.1 — the app keeps itself running, at the cost of a permanent notice in the shade | No — Android freezes the page after a few minutes |
|
||||
| QR scanning, file downloads | Yes (QR needs `https://`) | Yes (QR needs `https://`) |
|
||||
|
||||
---
|
||||
|
||||
@@ -10,6 +10,39 @@ For deep technical notes, see [architecture.md](architecture.md). For the full g
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Android app
|
||||
|
||||
- **Notifications now keep arriving while the app is in the background.** Until
|
||||
now they went quiet a few minutes after you switched away, and no setting on
|
||||
the phone fixed it — Android freezes an app that has nothing running in the
|
||||
foreground, and a frozen app holds no connection to your server and runs no
|
||||
code to alert you with. "Unrestricted" battery use never helped because it
|
||||
exempts an app from Doze, which is a different mechanism. The app now asks
|
||||
Android to leave it running, which Android grants in exchange for a
|
||||
**permanent, silent notice in the shade** — *Listening for new messages*. That
|
||||
notice appears **only once you turn notifications on in mc-webui itself**, so
|
||||
anyone who does not use them never sees it, and it disappears the moment you
|
||||
turn them off. Its **Stop** button ends it for the rest of the session, and it
|
||||
can be silenced permanently on its own in Android's notification settings,
|
||||
under **Background delivery**. Nothing new is sent anywhere: the connection
|
||||
held open is the one to your own server, and the alerts are still built by the
|
||||
mc-webui page itself — which is why mutes, blocked senders, channel names,
|
||||
translations and the tap-to-open-the-conversation behaviour all keep working
|
||||
exactly as they already did.
|
||||
- **The address screen remembers the servers you have used.** If you run more
|
||||
than one instance — your own and the public demo, say, or a test box next to a
|
||||
live one — switching between them meant typing the whole address again every
|
||||
time. The screen behind **Back → Change server** now lists the six most recent
|
||||
addresses under **Recent servers**; tap one and the app connects straight away,
|
||||
with the username and password saved for that server still applying, since
|
||||
those were always kept per address. The **✕** beside an entry forgets the
|
||||
shortcut without touching the server you are on or its saved login, and only
|
||||
addresses that actually answered are ever listed, so a typo never joins them.
|
||||
|
||||
> **Both need the new app version.** They are changes to the Android app, not to
|
||||
> mc-webui itself — the app updates itself from Google Play, or can be
|
||||
> reinstalled from the `.apk` in this repository.
|
||||
|
||||
---
|
||||
|
||||
## 2.15.0 — 2026-09-05
|
||||
|
||||
Reference in new issue
Block a user