feat(android): add wrapper sources and fix the address, camera and downloads

The wrapper is what users sideload, so its source belongs next to the APK -
they can read what they install, or build it themselves.

Behaviour fixes on the way in (APK rebuild pending):

- The saved server address survives. Back on the first page and connection
  errors used to delete it, so a stray tap or a moment without signal meant
  typing the address again; the form now opens pre-filled and only a save
  replaces what is stored. Back at the top level asks: exit, change server,
  or cancel
- QR scanning works: the page's camera request is mirrored to an Android
  permission request (CAMERA, on an https instance - getUserMedia needs a
  secure context, as in any browser)
- Downloads work: a DownloadListener hands database backups and other files
  to DownloadManager, which puts them in the phone's Downloads folder
- Links to other hosts and non-http schemes open in the system browser, so
  a URL in a message no longer navigates the app away from the instance
- Rotating the screen no longer reloads the page

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-07-28 21:05:42 +02:00
parent f71cac8b2e
commit 0cbab03ab8
14 changed files with 570 additions and 0 deletions
+10
View File
@@ -80,6 +80,16 @@ data/
*.sqlite
*.db
# ============================================
# Android wrapper app (android/src)
# ============================================
.gradle/
local.properties
# Signing keys never belong in the repository
*.jks
*.keystore
keystore.properties
# ============================================
# OS
# ============================================
+77
View File
@@ -0,0 +1,77 @@
# Android companion app — source
The app users install is a thin WebView wrapper around their own mc-webui
instance: one screen for the server address, one full-screen WebView for the
interface itself. It contains no mesh logic and talks to nothing except the
server the user typed in.
The sources are here so that anyone installing the `.apk` can see what they are
installing, and build it themselves if they prefer. For install instructions,
see [Android App](../docs/android-app.md).
| | |
|---|---|
| **Published build** | [`mc-webui-wrapper.apk`](mc-webui-wrapper.apk) |
| **Package** | `it.wojtaszek.mc.wrapper` |
| **Min / target SDK** | 21 (Android 5.0) / 34 |
| **Permissions** | `INTERNET`; `CAMERA` for QR scanning; `WRITE_EXTERNAL_STORAGE` (Android 9 and older) for saving downloads |
## Layout
```
src/
├── settings.gradle.kts, build.gradle.kts, gradle.properties
├── gradle/wrapper/gradle-wrapper.properties
└── app/
├── build.gradle.kts
└── src/main/
├── AndroidManifest.xml
├── java/it/wojtaszek/mc/wrapper/MainActivity.kt ← the whole app
└── res/ layout, strings, icons
```
`MainActivity.kt` is the entire application. Worth knowing about it:
- The server address is stored in `SharedPreferences` and **only ever replaced
by the user**. A failed connection or a Back press shows the address form
pre-filled — it never wipes what was saved
- Back on the first mc-webui page asks: exit, change server, or cancel
- Links to other hosts (URLs in messages, the packet analyzer) and non-`http`
schemes are handed to the system, so the app stays on the user's instance
- The page's camera request (QR scanning) is mirrored to an Android permission
request; downloads go to the phone's Downloads folder via `DownloadManager`
## Building
Open `src/` in Android Studio (the Gradle wrapper JAR is not checked in —
Android Studio supplies it) and let it sync. Then:
- **Debug build:** *Build → Build Bundle(s) / APK(s) → Build APK(s)*
`app/build/outputs/apk/debug/app-debug.apk`. Fine for trying things out on
your own phone, not for publishing — it is signed with the throwaway debug
key and is marked debuggable
- **Release build:** *Build → Generate Signed App Bundle or APK → APK*, pick
the project keystore, choose the `release` variant, and let it build. The
result is what ships
### Signing
Android identifies an app by its package name **and its signing key**. An
update only installs over an existing app when both match, so a release signed
with a different key forces every user to uninstall first — losing their saved
server address. In practice this means:
- Use the **same keystore for every release**, from the first published one
- **Back it up** (and its passwords) somewhere that survives a reinstalled
laptop. There is no way to recover or reissue it
- Never commit the keystore or its passwords to this repository
### Publishing a new build
1. Bump `versionCode` (and usually `versionName`) in `src/app/build.gradle.kts`
2. Build the signed release APK
3. Copy it here as `mc-webui-wrapper.apk`
4. Update the version, size and **SHA-256** in
[`docs/android-app.md`](../docs/android-app.md) — `sha256sum` on Linux,
`Get-FileHash` in PowerShell
5. Mention the change in [`docs/whatsnew.md`](../docs/whatsnew.md)
+38
View File
@@ -0,0 +1,38 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "it.wojtaszek.mc.wrapper"
compileSdk = 34
defaultConfig {
applicationId = "it.wojtaszek.mc.wrapper"
minSdk = 21
targetSdk = 34
versionCode = 1
versionName = "1.0"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("com.google.android.material:material:1.11.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
}
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<!-- QR scanning in Add Contact; the page asks first, Android asks second -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- Saving downloads (e.g. database backups) before Android 10 -->
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<!-- A phone without a camera can still do everything else -->
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<application
android:allowBackup="true"
android:usesCleartextTraffic="true"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.AppCompat.Light.NoActionBar">
<activity
android:name=".MainActivity"
android:configChanges="orientation|screenSize|keyboardHidden"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,280 @@
package it.wojtaszek.mc.wrapper
import android.Manifest
import android.app.DownloadManager
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.net.Uri
import android.net.http.SslError
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.view.View
import android.webkit.CookieManager
import android.webkit.PermissionRequest
import android.webkit.SslErrorHandler
import android.webkit.URLUtil
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Button
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
/**
* A thin wrapper around the user's own mc-webui instance: one screen to enter
* the server address, one full-screen WebView for the interface itself.
*
* The saved address is only ever replaced by the user - a failed connection or
* a stray Back press never makes them type it again.
*/
class MainActivity : AppCompatActivity() {
private lateinit var prefs: SharedPreferences
private lateinit var configLayout: LinearLayout
private lateinit var configMessage: TextView
private lateinit var webView: WebView
private lateinit var urlInput: EditText
/** Camera request from the page (QR scanning), waiting for the Android permission. */
private var pendingCameraRequest: PermissionRequest? = null
/** Download the page asked for, waiting for the storage permission on older Android. */
private var pendingDownload: (() -> Unit)? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
prefs = getSharedPreferences(PREFS, Context.MODE_PRIVATE)
configLayout = findViewById(R.id.configLayout)
configMessage = findViewById(R.id.configMessage)
webView = findViewById(R.id.webView)
urlInput = findViewById(R.id.urlInput)
setUpWebView()
findViewById<Button>(R.id.saveButton).setOnClickListener {
val typed = urlInput.text.toString().trim()
if (typed.isEmpty()) {
Toast.makeText(this, R.string.address_empty, Toast.LENGTH_SHORT).show()
return@setOnClickListener
}
// Without a scheme the address is ambiguous; https is the safe default,
// and the hint tells local users to type http:// themselves
val url = if (typed.startsWith("http://") || typed.startsWith("https://")) typed else "https://$typed"
prefs.edit().putString(KEY_URL, url).apply()
connect(url)
}
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() = goBack()
})
val savedUrl = prefs.getString(KEY_URL, null)
if (savedUrl.isNullOrEmpty()) showConfig(null) else connect(savedUrl)
}
// ---------------------------------------------------------------- screens
/**
* Shows the address form, pre-filled with the saved address. The saved
* address itself is left alone, so cancelling out of here changes nothing.
*/
private fun showConfig(message: String?) {
urlInput.setText(prefs.getString(KEY_URL, "") ?: "")
configMessage.text = message ?: ""
configMessage.visibility = if (message == null) View.GONE else View.VISIBLE
configLayout.visibility = View.VISIBLE
webView.visibility = View.GONE
}
private fun connect(url: String) {
configLayout.visibility = View.GONE
webView.visibility = View.VISIBLE
webView.loadUrl(url)
}
private fun goBack() {
if (configLayout.visibility == View.VISIBLE) {
finish()
return
}
if (webView.canGoBack()) {
webView.goBack()
return
}
// At the first page Back used to drop the saved address; now it asks
AlertDialog.Builder(this)
.setTitle(R.string.app_name)
.setMessage(R.string.leave_prompt)
.setPositiveButton(R.string.exit) { _, _ -> finish() }
.setNeutralButton(R.string.change_server) { _, _ -> showConfig(null) }
.setNegativeButton(R.string.cancel, null)
.show()
}
// --------------------------------------------------------------- web view
private fun setUpWebView() {
webView.settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
cacheMode = WebSettings.LOAD_DEFAULT
}
webView.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean =
handleUrl(request?.url)
@Suppress("DEPRECATION")
override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean =
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))
}
override fun onReceivedSslError(view: WebView?, handler: SslErrorHandler?, error: SslError?) {
handler?.cancel()
showConfig(getString(R.string.error_ssl))
}
}
webView.webChromeClient = object : WebChromeClient() {
/** QR scanning asks the page for the camera; mirror that to Android. */
override fun onPermissionRequest(request: PermissionRequest?) {
if (request == null) return
runOnUiThread {
val wanted = request.resources.filter { it == PermissionRequest.RESOURCE_VIDEO_CAPTURE }
when {
wanted.isEmpty() -> request.deny()
hasPermission(Manifest.permission.CAMERA) -> request.grant(wanted.toTypedArray())
else -> {
pendingCameraRequest = request
ActivityCompat.requestPermissions(
this@MainActivity, arrayOf(Manifest.permission.CAMERA), REQ_CAMERA
)
}
}
}
}
}
// Database backups and other files would silently do nothing otherwise
webView.setDownloadListener { url, userAgent, contentDisposition, mimeType, _ ->
download(url, userAgent, contentDisposition, mimeType)
}
}
/**
* Keeps mc-webui itself inside the app and hands everything else - links in
* messages, the packet analyzer, mailto: and friends - to the system.
*
* @return true when the URL was handled outside the WebView.
*/
private fun handleUrl(uri: Uri?): Boolean {
if (uri == null) return false
val scheme = uri.scheme?.lowercase()
if (scheme != "http" && scheme != "https") {
openExternally(uri)
return true
}
val serverHost = Uri.parse(prefs.getString(KEY_URL, "") ?: "").host
if (serverHost != null && !serverHost.equals(uri.host, ignoreCase = true)) {
openExternally(uri)
return true
}
return false
}
private fun openExternally(uri: Uri) {
try {
startActivity(Intent(Intent.ACTION_VIEW, uri))
} catch (e: ActivityNotFoundException) {
Toast.makeText(this, R.string.no_app_for_link, Toast.LENGTH_SHORT).show()
}
}
// -------------------------------------------------------------- downloads
private fun download(url: String, userAgent: String?, contentDisposition: String?, mimeType: String?) {
// Writing to the public Downloads folder needs permission before Android 10
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q &&
!hasPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)
) {
pendingDownload = { download(url, userAgent, contentDisposition, mimeType) }
ActivityCompat.requestPermissions(
this, arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), REQ_STORAGE
)
return
}
try {
val fileName = URLUtil.guessFileName(url, contentDisposition, mimeType)
val request = DownloadManager.Request(Uri.parse(url)).apply {
setMimeType(mimeType)
setTitle(fileName)
setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName)
setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFIED)
if (!userAgent.isNullOrEmpty()) addRequestHeader("User-Agent", userAgent)
CookieManager.getInstance().getCookie(url)?.let { addRequestHeader("Cookie", it) }
}
(getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager).enqueue(request)
Toast.makeText(this, getString(R.string.downloading, fileName), Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
Toast.makeText(this, getString(R.string.download_failed, e.message ?: ""), Toast.LENGTH_LONG).show()
}
}
// ------------------------------------------------------------ permissions
private fun hasPermission(permission: String): Boolean =
ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
val granted = grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED
when (requestCode) {
REQ_CAMERA -> {
val request = pendingCameraRequest
pendingCameraRequest = null
if (granted) {
request?.grant(arrayOf(PermissionRequest.RESOURCE_VIDEO_CAPTURE))
} else {
request?.deny()
Toast.makeText(this, R.string.camera_denied, Toast.LENGTH_LONG).show()
}
}
REQ_STORAGE -> {
val download = pendingDownload
pendingDownload = null
if (granted) download?.invoke()
else Toast.makeText(this, R.string.storage_denied, Toast.LENGTH_LONG).show()
}
}
}
companion object {
private const val PREFS = "MC_PREFS"
private const val KEY_URL = "SERVER_URL"
private const val REQ_CAMERA = 1
private const val REQ_STORAGE = 2
}
}
@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#ffffff">
<!-- Server address form -->
<LinearLayout
android:id="@+id/configLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="32dp"
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"
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="wrap_content"
android:layout_height="wrap_content"
android:text="@string/address_label"
android:layout_marginBottom="8dp"/>
<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"/>
</LinearLayout>
<!-- mc-webui itself -->
<WebView
android:id="@+id/webView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone" />
</androidx.constraintlayout.widget.ConstraintLayout>
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

@@ -0,0 +1,27 @@
<resources>
<string name="app_name">mc-webui</string>
<!-- Server address form -->
<string name="address_label">Enter your server address:</string>
<string name="address_hint">e.g. https://your-server-ip-or-domain</string>
<string name="address_note">Note: Use https:// for secure connection. If you are on a local network without SSL, you can use http:// (e.g., http://192.168.1.100).</string>
<string name="save_and_connect">SAVE &amp; CONNECT</string>
<string name="address_empty">Address cannot be empty</string>
<!-- Leaving the app -->
<string name="leave_prompt">Leave mc-webui, or connect to a different server?</string>
<string name="exit">Exit</string>
<string name="change_server">Change server</string>
<string name="cancel">Cancel</string>
<!-- Connection problems -->
<string name="error_unreachable">Could not reach the server. Check the address, and that the phone is on the right network.</string>
<string name="error_ssl">SSL error - the certificate was rejected. Use a valid HTTPS certificate, or plain http:// on a local network.</string>
<string name="no_app_for_link">No app on the phone can open this link</string>
<!-- Downloads and permissions -->
<string name="downloading">Downloading %1$s</string>
<string name="download_failed">Download failed: %1$s</string>
<string name="camera_denied">Camera permission denied - QR scanning needs it</string>
<string name="storage_denied">Storage permission denied - the file cannot be saved</string>
</resources>
+16
View File
@@ -0,0 +1,16 @@
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath("com.android.tools.build:gradle:8.2.0")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.22")
}
}
allprojects {
repositories {
google()
mavenCentral()
}
}
+2
View File
@@ -0,0 +1,2 @@
android.useAndroidX=true
android.enableJetifier=true
+5
View File
@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+2
View File
@@ -0,0 +1,2 @@
rootProject.name = "mc-webui-wrapper"
include(":app")
+4
View File
@@ -161,6 +161,10 @@ full-screen WebView for mc-webui itself, and a saved preference between them.
No analytics, no third-party services, no background activity — when the app is
closed, nothing of it runs.
The complete source is in [`android/src/`](../android/src), and
[`android/README.md`](../android/README.md) describes how to build it yourself
if you would rather not trust a prebuilt `.apk`.
---
## See also