diff --git a/.gitignore b/.gitignore
index f0df187..cefc90f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
# ============================================
diff --git a/android/README.md b/android/README.md
new file mode 100644
index 0000000..bbbceef
--- /dev/null
+++ b/android/README.md
@@ -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)
diff --git a/android/src/app/build.gradle.kts b/android/src/app/build.gradle.kts
new file mode 100644
index 0000000..32e61de
--- /dev/null
+++ b/android/src/app/build.gradle.kts
@@ -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")
+}
diff --git a/android/src/app/src/main/AndroidManifest.xml b/android/src/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..66ca731
--- /dev/null
+++ b/android/src/app/src/main/AndroidManifest.xml
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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
new file mode 100644
index 0000000..efae762
--- /dev/null
+++ b/android/src/app/src/main/java/it/wojtaszek/mc/wrapper/MainActivity.kt
@@ -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