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:
MarekWoandClaude Opus 5 committed 2026-09-06 10:45:26 +02:00
1 parent 2afa9c182b
commit fefa43d30d
13 files changed
+625 -24

No files matched your search

+22 -5
View File
@@ -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
+2 -2
View File
@@ -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 &amp; 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
View File
@@ -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
+32 -2
View File
@@ -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
+5
View File
@@ -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://`) |
---
+33
View File
@@ -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