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