diff --git a/android/src/app/build.gradle.kts b/android/src/app/build.gradle.kts index 97c673d..d13ad01 100644 --- a/android/src/app/build.gradle.kts +++ b/android/src/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "it.wojtaszek.mc.wrapper" minSdk = 21 targetSdk = 34 - versionCode = 3 - versionName = "1.2" + versionCode = 4 + versionName = "1.3" } buildTypes { 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 36e55eb..59bc141 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 @@ -19,6 +19,7 @@ import android.os.Bundle import android.os.Environment import android.view.View import android.webkit.CookieManager +import android.webkit.HttpAuthHandler import android.webkit.JavascriptInterface import android.webkit.PermissionRequest import android.webkit.SslErrorHandler @@ -207,6 +208,29 @@ class MainActivity : AppCompatActivity() { handler?.cancel() showConfig(getString(R.string.error_ssl)) } + + /** + * Only ever called when the server answers 401 with a + * `WWW-Authenticate` header - a server that asks for no password + * never reaches this, so nothing changes for those users. + */ + override fun onReceivedHttpAuthRequest( + view: WebView?, + handler: HttpAuthHandler?, + host: String?, + realm: String? + ) { + if (handler == null) return + val saved = savedCredentials(host) + // useHttpAuthUsernamePassword() is true only on the first ask for + // a request; a second one means the server turned those exact + // credentials down, so ask the user rather than retry forever + if (handler.useHttpAuthUsernamePassword() && saved != null) { + handler.proceed(saved.first, saved.second) + return + } + askForCredentials(handler, host, saved?.first) + } } webView.webChromeClient = object : WebChromeClient() { @@ -264,6 +288,63 @@ class MainActivity : AppCompatActivity() { } } + // ----------------------------------------------------------- http auth + + /** + * Asks for the username and password the server wants, then remembers them + * so the next launch goes straight in. + * + * They are stored before the server has had a chance to accept them: a + * wrong password simply comes back here on the next 401, with the username + * filled in, and overwrites what was saved. + */ + private fun askForCredentials(handler: HttpAuthHandler, host: String?, knownUser: String?) { + val form = layoutInflater.inflate(R.layout.dialog_auth, null) + val userInput = form.findViewById(R.id.authUser) + val passwordInput = form.findViewById(R.id.authPassword) + form.findViewById(R.id.authMessage).text = + getString(R.string.auth_message, host ?: "") + knownUser?.let { userInput.setText(it) } + + val giveUp = { + handler.cancel() + showConfig(getString(R.string.error_auth_cancelled)) + } + + AlertDialog.Builder(this) + .setTitle(R.string.auth_title) + .setView(form) + .setPositiveButton(R.string.auth_sign_in) { _, _ -> + val user = userInput.text.toString() + val password = passwordInput.text.toString() + saveCredentials(host, user, password) + handler.proceed(user, password) + } + .setNegativeButton(R.string.cancel) { _, _ -> giveUp() } + .setOnCancelListener { giveUp() } + .show() + } + + /** + * Credentials live in the app's own private preferences, keyed by host so + * that switching servers does not carry them over. That file is readable + * only by this app, which is as far as a wrapper can reasonably go. + */ + private fun savedCredentials(host: String?): Pair? { + if (host.isNullOrEmpty()) return null + val user = prefs.getString(KEY_AUTH_USER + host, null) ?: return null + val password = prefs.getString(KEY_AUTH_PASSWORD + host, null) ?: return null + return user to password + } + + private fun saveCredentials(host: String?, user: String, password: String) { + if (host.isNullOrEmpty()) return + prefs.edit() + .putString(KEY_AUTH_USER + host, user) + .putString(KEY_AUTH_PASSWORD + host, password) + .apply() + } + // ---------------------------------------------------------- notifications /** @@ -471,6 +552,9 @@ class MainActivity : AppCompatActivity() { private const val PREFS = "MC_PREFS" private const val KEY_URL = "SERVER_URL" 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_" + private const val KEY_AUTH_PASSWORD = "AUTH_PASSWORD_" private const val REQ_CAMERA = 1 private const val REQ_STORAGE = 2 private const val REQ_NOTIFICATIONS = 3 diff --git a/android/src/app/src/main/res/layout/dialog_auth.xml b/android/src/app/src/main/res/layout/dialog_auth.xml new file mode 100644 index 0000000..7c37691 --- /dev/null +++ b/android/src/app/src/main/res/layout/dialog_auth.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + diff --git a/android/src/app/src/main/res/values/strings.xml b/android/src/app/src/main/res/values/strings.xml index e48e883..d65e991 100644 --- a/android/src/app/src/main/res/values/strings.xml +++ b/android/src/app/src/main/res/values/strings.xml @@ -14,9 +14,17 @@ Change server Cancel + + Sign in + %1$s asks for a username and password. + Username + Password + SIGN IN + Could not reach the server. Check the address, and that the phone is on the right network. SSL error - the certificate was rejected. Use a valid HTTPS certificate, or plain http:// on a local network. + This server asks for a username and password. Connect again to enter them. No app on the phone can open this link diff --git a/docs/android-app.md b/docs/android-app.md index b431cc8..caf2a0c 100644 --- a/docs/android-app.md +++ b/docs/android-app.md @@ -124,6 +124,23 @@ wrong address, server down, phone off the network. Either way the form opens **pre-filled with the address you are using**, and nothing is overwritten until you tap **SAVE & CONNECT**: a dropped connection never costs you the address. +### Servers that ask for a password + +If your instance sits behind a reverse proxy configured to require a login +(HTTP Basic authentication — for example an **Access List** in Nginx Proxy +Manager), the app asks for it the first time it connects, from version 1.3: + +> **Sign in** — *mc-webui.example.com asks for a username and password.* + +Enter the username and password your proxy expects and tap **SIGN IN**. They are +remembered per server address, so later launches go straight in. Get them wrong +and the same prompt comes back on the next attempt, with the username filled in. +Tapping **Cancel** returns to the address form. + +This only ever appears when the server actually asks for a password. On an +instance without one — local `http://` or a proxy with no access control — you +will never see this screen, and nothing about the app changes. + --- ## What works, and what doesn't @@ -149,13 +166,17 @@ A couple of things depend on how your instance is reachable: - **Install the `.apk` only from this repository** (or from the project's GitHub Releases page). An `.apk` from anywhere else is a different app, no matter what it is called -- The app stores exactly one thing on the phone: **the server address you - typed**. No messages, keys, or contacts — those stay on your server and your - MeshCore device +- The app stores two things on the phone: **the server address you typed**, and + — only if your server asks for a login — **the username and password for it**. + Both live in the app's private storage, which no other app can read. No + messages, keys, or contacts — those stay on your server and your MeshCore + device - **Plain `http://` is allowed** so that local instances work without a certificate. On a home network that is fine; over the internet, put mc-webui - behind a reverse proxy with HTTPS and some form of access control — the app - adds no authentication of its own + behind a reverse proxy with HTTPS and some form of access control. The app has + no login of its own, but it does **support a proxy that requires one** (see + *Servers that ask for a password* above) — and over `https://` those + credentials are encrypted in transit like everything else - **Permissions:** internet access; the camera, only when you use QR scanning and only after you allow it; notifications, only after you turn them on in the menu and allow them; and file storage on Android 9 and older, only for