mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-11-10 10:42:21 +01:00
feat(android): background notifications and remembered servers
Two changes to the Android wrapper, shipping together as app version 2.1 (versionCode 6). Notifications stopped arriving a few minutes after the app was backgrounded, and no setting on the phone could prevent it: Android freezes a process with nothing in the foreground, and a frozen process runs no JavaScript and holds no socket. MeshWatchService is a foreground service of type remoteMessaging that does no work of its own - it exists so Android leaves the process alone. Every notification is still built by the mc-webui page, so mutes, blocked senders, channel names, translations and deep links stay in one place rather than being duplicated in Kotlin. The service follows mc-webui's own notification toggle, read from localStorage by the shim rather than asked of the page, because the wrapper connects to whichever server its user typed in - very often an older mc-webui. The shim also wraps Storage.prototype.setItem, since localStorage fires no event in the document that wrote it. Android's own switches have the last word, so a revoked permission or a silenced channel stops the service too. setRendererPriorityPolicy goes with it: the page renders in a process of its own, and WebView waives that process's priority once the view stops being visible, so a service saving only the host process would still lose the renderer under memory pressure. Separately, the address screen now lists the servers reached before, so running more than one instance no longer means retyping the whole address to switch. Only addresses that answered are listed, which keeps typos out. Also replaces the deprecated setDecorFitsSystemWindows with enableEdgeToEdge, which is what Play Console has been flagging twice. Its default navigation-bar scrim is overridden, or it would paint over the strip the wrapper colours to match the page below Android 10. Docs, the privacy policy (both languages) and the app-vs-PWA comparison are updated: three of them said the app ran nothing in the background. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2afa9c182b
commit
fefa43d30d
13 files changed
+666
-65
No files matched your search
@@ -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,63 +6,93 @@
|
||||
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">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/app_name"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:layout_marginBottom="24dp"/>
|
||||
|
||||
<!-- Filled in when a connection fails; hidden otherwise -->
|
||||
<TextView
|
||||
android:id="@+id/configMessage"
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="13sp"
|
||||
android:textColor="#B00020"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:visibility="gone"/>
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="vertical"
|
||||
android:padding="32dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/address_label"
|
||||
android:layout_marginBottom="8dp"/>
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/app_name"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:layout_marginBottom="24dp"/>
|
||||
|
||||
<EditText
|
||||
android:id="@+id/urlInput"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/address_hint"
|
||||
android:inputType="textUri"
|
||||
android:layout_marginBottom="8dp"/>
|
||||
<!-- Filled in when a connection fails; hidden otherwise -->
|
||||
<TextView
|
||||
android:id="@+id/configMessage"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="13sp"
|
||||
android:textColor="#B00020"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:visibility="gone"/>
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/address_note"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#666666"
|
||||
android:layout_marginBottom="16dp"/>
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/address_label"
|
||||
android:layout_marginBottom="8dp"/>
|
||||
|
||||
<Button
|
||||
android:id="@+id/saveButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/save_and_connect"/>
|
||||
</LinearLayout>
|
||||
<EditText
|
||||
android:id="@+id/urlInput"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/address_hint"
|
||||
android:inputType="textUri"
|
||||
android:layout_marginBottom="8dp"/>
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/address_note"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#666666"
|
||||
android:layout_marginBottom="16dp"/>
|
||||
|
||||
<Button
|
||||
android:id="@+id/saveButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/save_and_connect"/>
|
||||
|
||||
<!-- Servers reached before. Both this and the list below are hidden
|
||||
until there is something to show, so a first run looks exactly
|
||||
as it always did. -->
|
||||
<TextView
|
||||
android:id="@+id/historyLabel"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="28dp"
|
||||
android:text="@string/recent_servers"
|
||||
android:textColor="#666666"
|
||||
android:textSize="12sp"
|
||||
android:visibility="gone"/>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/historyList"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:orientation="vertical"
|
||||
android:visibility="gone"/>
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
|
||||
<!-- mc-webui itself -->
|
||||
<WebView
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- One remembered server address: tap the row to connect, tap the cross to
|
||||
forget it. Sized for a finger rather than for the text it holds. -->
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:gravity="center_vertical"
|
||||
android:minHeight="48dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<!-- Long addresses lose their middle, which keeps both the host and the
|
||||
port readable - the two halves that tell servers apart -->
|
||||
<TextView
|
||||
android:id="@+id/serverAddress"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:ellipsize="middle"
|
||||
android:paddingStart="4dp"
|
||||
android:paddingEnd="8dp"
|
||||
android:singleLine="true"
|
||||
android:textColor="#1565C0"
|
||||
android:textSize="15sp" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/serverForget"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/forget_server"
|
||||
android:src="@android:drawable/ic_menu_close_clear_cancel" />
|
||||
|
||||
</LinearLayout>
|
||||
@@ -8,6 +8,10 @@
|
||||
<string name="save_and_connect">SAVE & CONNECT</string>
|
||||
<string name="address_empty">Address cannot be empty</string>
|
||||
|
||||
<!-- Servers connected to before, for switching back without retyping -->
|
||||
<string name="recent_servers">Recent servers</string>
|
||||
<string name="forget_server">Forget this server</string>
|
||||
|
||||
<!-- Leaving the app -->
|
||||
<string name="leave_prompt">Leave mc-webui, or connect to a different server?</string>
|
||||
<string name="exit">Exit</string>
|
||||
@@ -37,4 +41,12 @@
|
||||
<string name="notification_channel_name">Mesh activity</string>
|
||||
<string name="notification_channel_description">New messages and pending contacts from your mc-webui server</string>
|
||||
<string name="notifications_denied">Notification permission denied - mc-webui cannot alert you about new messages</string>
|
||||
|
||||
<!-- Background delivery. Android insists on a permanent notice in return
|
||||
for leaving the app running, so it may as well say what it is for -->
|
||||
<string name="background_channel_name">Background delivery</string>
|
||||
<string name="background_channel_description">The silent, permanent notice shown while mc-webui keeps listening for new messages in the background</string>
|
||||
<string name="background_title">Listening for new messages</string>
|
||||
<string name="background_text">mc-webui stays connected while you are in another app, so new messages still reach you.</string>
|
||||
<string name="background_stop">Stop</string>
|
||||
</resources>
|
||||
Reference in new issue
Block a user