diff --git a/android/README.md b/android/README.md index 9485b2a..4e5b7e0 100644 --- a/android/README.md +++ b/android/README.md @@ -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 diff --git a/android/src/app/build.gradle.kts b/android/src/app/build.gradle.kts index 2399759..e3b3806 100644 --- a/android/src/app/build.gradle.kts +++ b/android/src/app/build.gradle.kts @@ -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 { diff --git a/android/src/app/src/main/AndroidManifest.xml b/android/src/app/src/main/AndroidManifest.xml index ec6df96..b18e2e4 100644 --- a/android/src/app/src/main/AndroidManifest.xml +++ b/android/src/app/src/main/AndroidManifest.xml @@ -7,6 +7,11 @@ + + + + + + diff --git a/android/src/app/src/main/assets/notification_shim.js b/android/src/app/src/main/assets/notification_shim.js index 878c4c3..94fd0f7 100644 --- a/android/src/app/src/main/assets/notification_shim.js +++ b/android/src/app/src/main/assets/notification_shim.js @@ -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); })(); diff --git a/android/src/app/src/main/java/it/wojtaszek/mc/wrapper/MainActivity.kt b/android/src/app/src/main/java/it/wojtaszek/mc/wrapper/MainActivity.kt index 9041a94..f5abbd2 100644 --- a/android/src/app/src/main/java/it/wojtaszek/mc/wrapper/MainActivity.kt +++ b/android/src/app/src/main/java/it/wojtaszek/mc/wrapper/MainActivity.kt @@ -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 { + val stored = prefs.getString(KEY_HISTORY, null) ?: return mutableListOf() + return try { + val entries = JSONArray(stored) + val list = mutableListOf() + 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) { + 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(R.id.serverAddress).text = url + row.setOnClickListener { useServer(url) } + row.findViewById(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" diff --git a/android/src/app/src/main/java/it/wojtaszek/mc/wrapper/MeshWatchService.kt b/android/src/app/src/main/java/it/wojtaszek/mc/wrapper/MeshWatchService.kt new file mode 100644 index 0000000..52230e2 --- /dev/null +++ b/android/src/app/src/main/java/it/wojtaszek/mc/wrapper/MeshWatchService.kt @@ -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)) + } + } +} diff --git a/android/src/app/src/main/res/layout/activity_main.xml b/android/src/app/src/main/res/layout/activity_main.xml index 43edebd..9f9fd02 100644 --- a/android/src/app/src/main/res/layout/activity_main.xml +++ b/android/src/app/src/main/res/layout/activity_main.xml @@ -6,63 +6,93 @@ android:layout_height="match_parent" android:background="#ffffff"> - - + - - - - + android:gravity="center_vertical" + android:orientation="vertical" + android:padding="32dp"> - + - + + - + -