From fefa43d30dce2bb1977253d31d32ade03fdd51c7 Mon Sep 17 00:00:00 2001 From: MarekWo Date: Sun, 6 Sep 2026 10:45:26 +0200 Subject: [PATCH] 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 --- android/README.md | 27 ++- android/src/app/build.gradle.kts | 4 +- android/src/app/src/main/AndroidManifest.xml | 12 ++ .../app/src/main/assets/notification_shim.js | 47 +++++ .../it/wojtaszek/mc/wrapper/MainActivity.kt | 171 +++++++++++++++++- .../wojtaszek/mc/wrapper/MeshWatchService.kt | 168 +++++++++++++++++ .../app/src/main/res/layout/activity_main.xml | 122 ++++++++----- .../app/src/main/res/layout/item_server.xml | 34 ++++ .../src/app/src/main/res/values/strings.xml | 12 ++ docs/android-app.md | 62 ++++++- docs/privacy-policy.html | 34 +++- docs/user-guide.md | 5 + docs/whatsnew.md | 33 ++++ 13 files changed, 666 insertions(+), 65 deletions(-) create mode 100644 android/src/app/src/main/java/it/wojtaszek/mc/wrapper/MeshWatchService.kt create mode 100644 android/src/app/src/main/res/layout/item_server.xml 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"> - + - + + - + -