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
+666 -65

No files matched your search

+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,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 &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>