diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 9fb8d50..0000000 --- a/.gitignore +++ /dev/null @@ -1,25 +0,0 @@ -# Gradle / build output -.gradle/ -build/ -/app/build/ - -# Local SDK / signing config and secrets (never commit) -local.properties -*.keystore -*.jks -keystore.properties -signing.properties - -# Android Studio / IntelliJ -.idea/ -*.iml -captures/ -.externalNativeBuild/ -.cxx/ - -# OS cruft -.DS_Store -Thumbs.db - -# Claude Code session state -.claude/ diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index d59a34e..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,103 +0,0 @@ -# Changelog — PON Go (Android) - -All notable changes to this app. The **★ Port to iOS** section in each entry calls -out changes that affect shared REST behaviour / security / device-safety and most -likely have an equivalent in the sibling iOS app (`../mcms_ios`) — the two share the -same MCMS field paths and request semantics (see `docs/MCMS_API.md`). - ---- - -## [Unreleased] — Security hardening & code-review fixes (2026-05-31) - -Outcome of a full code review. No `versionCode` bump yet — bump before release. -Release build now uses R8; **run `./gradlew :app:assembleDebug` and a `release` -build to verify** (the dev machine for this change had no Android SDK). - -### Security - -- **Removed untrusted-TLS support entirely.** Deleted `ServerTrustPolicy.AllowSelfSignedForHost` - and its all-accepting `X509TrustManager` (it accepted *any* certificate for the - configured host — a full MITM hole on a tool that can reset gear and push firmware). - Removed the "Allow self-signed certificate" toggle from Settings. Only system-CA - trust remains, plus the still-available `ServerTrustPolicy.PinPublicKeySha256` for - pinning a known box's key. *(`core/Networking.kt`, `core/ApiConfiguration.kt`, `ui/AuthScreens.kt`)* -- **Enforced HTTPS.** `normalizedBaseUrl` now defaults a missing scheme to `https`, - rejects any non-`https` URL, and rejects embedded credentials. Added - `res/xml/network_security_config.xml` (cleartext forbidden, system CAs only — blocks - the user-installed-CA MITM vector) + `usesCleartextTraffic="false"`. *(`core/ApiConfiguration.kt`, `AndroidManifest.xml`)* -- **Hardened stored credentials.** Passwords are no longer persisted at rest: - "Remember me" stores the **email only**; the password field is never pre-filled and - is kept out of the saved-instance `Bundle` (plain `remember`, not `rememberSaveable`). - Credentials are cleared on sign-out and when "Remember me" is unchecked; any legacy - stored password is purged. `allowBackup="false"`. *(`ui/AppState.kt`, `ui/AuthScreens.kt`, `AndroidManifest.xml`)* -- **CSRF cookie lookup** now resolves the host via OkHttp's parser (matching the cookie - jar) so the `X-CSRFToken` lookup can't silently miss on host-normalisation edge cases. -- Unexpected (non-`ApiError`) throwables no longer leak raw library text to the UI. *(`ui/Types.kt`)* - -### Correctness - -- **Per-endpoint API versioning.** Endpoint paths are now fixed at `v1`/`v3` per - `docs/MCMS_API.md` §4 instead of one global `apiVersion` (which was wrong for *every* - config: lists/states need `v3`, single-doc/login/logs need `v1` — no single value - worked). Removed the global `apiVersion` config field, the `version` override plumbing, - and the API-version Settings input. Versions for undocumented endpoints (controllers, - `olts/configs`, reset, `ponmgr/version`) are inferred and marked `// inferred`. *(`core/McmsEndpoint.kt`, `core/ApiClient.kt`)* -- **`"warning"` status treated as success.** The `{status,data}` unwrap now accepts - `warning` as well as `success` (a `warning` means the write committed; only a schema - mismatch is reported) — previously a committed firmware/config write was reported as - failed. *(`core/ApiClient.kt`)* -- **Pagination fails loud, not silent.** `getAll` now throws instead of returning a - truncated fleet when a full page yields no usable cursor (e.g. a projection drops - `_id`). *(`core/ApiClient.kt`)* -- **`Response` no longer leaks on coroutine cancellation** (resume-with-onCancellation - closes a dropped response — prevents connection-pool exhaustion on navigate-away). *(`core/ApiClient.kt`)* -- **Firmware compatibility now fails closed.** `FirmwareFile.isCompatible` returns - compatible only when vendor + equipment id are known and the file positively matches - (was: unknown/missing metadata ⇒ "compatible"). Staging a non-compatible image now - requires an explicit extra acknowledgment in the dialog. Mis-flashing can brick an ONU. *(`core/Firmware.kt`, `ui/OnuScreens.kt`)* -- **JSON parse is tolerant.** A non-JSON 2xx body yields a typed `ApiError.Decoding` - instead of crashing. A required non-empty `User-Agent` header is now sent (§3.1). *(`core/ApiClient.kt`)* - -### Concurrency / lifecycle - -- **Network I/O off the main thread** (`Dispatchers.IO`) — fixes ANR/jank on large - fleets; the `onAuthExpired` Compose-state write stays on the main thread. *(`core/ApiClient.kt`)* -- **Stale-result generation guards** in every screen `load()`/`reload()`: a slow earlier - load can no longer overwrite newer data, and `CancellationException` is rethrown (a - cancelled load is no longer shown as an error). Fixes the ONU-detail tab-switch race. *(`ui/*Screens.kt`)* -- **`AppState` hosted in a `ViewModel`** so rotation / dark-mode no longer rebuilds the - connection and bounces the user to login. *(`MainActivity.kt`)* -- Thread-safe `SessionCookieJar` (synchronised; OkHttp calls it from background threads). *(`core/Networking.kt`)* -- Re-entry guard on `reset()`/`upgrade()`; list-screen "Retry" buttons wired up; the - dashboard keeps its last-good data when a background refresh fails. *(`ui/*Screens.kt`)* - -### Build / Android - -- Release build enables R8 `isMinifyEnabled` + `isShrinkResources` with correct - kotlinx.serialization keep rules (so config (de)serialization survives shrinking). *(`app/build.gradle.kts`, `app/proguard-rules.pro`)* -- Dropped the unused `navigation-compose` dependency. Added the missing `.gitignore`. *(`app/build.gradle.kts`, `.gitignore`)* - -### ★ Port to iOS (`mcms_ios`) - -These touch shared REST/security/safety behaviour and very likely have an iOS analogue — -review and port: - -1. **Per-endpoint v1/v3 versioning** — if iOS uses a single global API version it has the - same "no value works for everything" defect. Pin versions per endpoint per `docs/MCMS_API.md` §4. -2. **`"warning"` = success** in the `{status,data}` unwrap — a committed write must not be - reported as a failure. -3. **Firmware `isCompatible` fail-closed** (+ extra acknowledgment for a non-compatible - image) — this is a device-brick safety fix, not cosmetic. -4. **Remove the self-signed "escape hatch."** HANDOFF notes the Android trust-all path - "matches the iOS self-signed escape hatch," so iOS almost certainly has the same MITM - hole. Remove it; rely on ATS + system trust, or pin the leaf key for a known box. -5. **Credential hardening** — store email only (never the password) in the Keychain; - clear on sign-out / when remember-me is off; don't pre-fill the password field. -6. **Reject `http://` at config time** even though iOS ATS blocks cleartext at the - transport layer — fail early with a clear message rather than a confusing transport error. -7. **Pagination fail-fast** when a full page has no cursor, rather than silently truncating. -8. **Send a non-empty `User-Agent`** (an empty UA is rejected by MCMS, §3.1). - -iOS-specific equivalents worth a glance but not 1:1 ports: cancel-safe request teardown -(don't leak the response on task cancellation), and not surfacing stale results when a -newer load is in flight. diff --git a/HANDOFF.md b/HANDOFF.md index d761edd..2167010 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -37,9 +37,7 @@ everything. Two loops: ``` Needs `local.properties` (`sdk.dir=…`, git-ignored). First run downloads deps to `~/.gradle`; afterwards it's ~20–60 s. This is what proves the whole app - (UI + resources + manifest + icons) compiles. **`release` now enables R8 - minify+shrink** (keep rules in `proguard-rules.pro`) — smoke-test a `release` build - before shipping, since R8 can strip serializers if the keep rules ever drift. + (UI + resources + manifest + icons) compiles. - **Quick core-only check (no Gradle), when iterating on `core/`:** the bundled compiler at `…/Android Studio.app/Contents/plugins/Kotlin/kotlinc/bin/kotlinc` with `-Xplugin=…/kotlinc/lib/kotlinx-serialization-compiler-plugin.jar` and a @@ -52,24 +50,16 @@ everything. Two loops: - **Single-encode URLs.** Ids stay RAW in `McmsEndpoint`; `ApiClient` encodes the path once via `HttpUrl.addPathSegments`. Pre-encoding double-encoded MAC colons (`:` → `%3A` → `%253A`) → MCMS "invalid id". -- **Endpoint versions are fixed per endpoint (`v1`/`v3`), NOT global.** Lists/states are - `v3`; single-doc/login/logs/firmware-files are `v1` (§4) — the two are not - interchangeable, so there is no global API-version setting. Hardcoded in - `McmsEndpoint.path()`; endpoints not in the §4 table are marked `// inferred`. - **401 bounces to login; 403 does NOT** (permission/CSRF — `requiresReauthentication` is 401-only). -- **Writes return `{"status":"success"}` (or `"warning"`) with no `data`** — the unwrap - tolerates both. `"warning"` means the write committed (schema mismatch only) and is - treated as success, NOT an error (§3.5). +- **Writes return `{"status":"success"}` with no `data`** — the unwrap tolerates it. - **ONU registration lives on OLT-STATE buckets** (`OltRepository.registrationMap`), not ONU-STATE. Friendly names are `ONU.Name` / `OLT.Name` (NOT `NETCONF.Name`). - **CPE DHCP** comes from the web helper `GET /api/cpe/onu//` — a versionless, bare `[code, cpe…]` array (use `ApiClient.getRaw`). - **Firmware** = Procedure 7: GET ONU-CFG → write filename/version into the **inactive** bank → full-doc PUT (`FirmwareUpgrade.apply`). Inactive-bank only, - so service stays up until the ONU reboots. `FirmwareFile.isCompatible` now fails - CLOSED (unknown vendor/model ⇒ not compatible-by-default) and staging a - non-compatible image needs an extra acknowledgment. Still worth testing on a spare ONU. + so service stays up until the ONU reboots. Still worth testing on a spare ONU. - Field paths were confirmed against live JSON; see `docs/MCMS_API.md` and the model accessors. @@ -83,11 +73,9 @@ everything. Two loops: drills ONU-state → filtered ONU list. - Toggles use `rememberSaveable` (survive rotation, not process death). Persist in prefs if you want parity with iOS `@AppStorage`. -- **TLS is HTTPS-only with system-CA trust; self-signed/cleartext support was removed** - (security — see `CHANGELOG.md`, not "easy to restore"). `res/xml/network_security_config.xml` - forbids cleartext and user-installed CAs. To reach a private-cert box, install its CA in - the device system store or pin its key via `ServerTrustPolicy.PinPublicKeySha256`. The - trust policy lives in `ServerTrust`. +- Self-signed/cleartext: the TLS trust policy is in `ServerTrust`. Plain-HTTP hosts + also need a `usesCleartextTraffic`/network-security-config exception (Android's + ATS analogue) — add when needed. ## Repo split diff --git a/README.md b/README.md index 34d1865..836b0d7 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,8 @@ compileSdk 36, minSdk 26, JBR 21**. ## What it does (feature parity with iOS) -- **Login / Settings** — HTTPS-only URL normalisation (`/api` auto-appended, `http` - rejected), test-connection probe, remember-me (**email only** — passwords are never - stored, Android Keystore), sign out. +- **Login / Settings** — URL normalisation (`/api` auto-appended), self-signed + TLS toggle, test-connection probe, remember-me (Android Keystore), sign out. - **Dashboard** — ONU/OLT/controller counts; total OLT traffic; a **Health** section (abnormal-Rx ONUs, laser-off OLTs) that drills into the affected devices; ONU-state breakdown; **Detailed stats** (opt-in Rx scan) and @@ -52,6 +51,4 @@ docs/MCMS_API.md the MCMS REST field guide (single source of truth) ``` See [`HANDOFF.md`](HANDOFF.md) for architecture notes, the build/verify loop, the -API gotchas, and what's deliberately trimmed vs iOS. Recent security/correctness -changes — and which of them to bring over to the iOS app — are in -[`CHANGELOG.md`](CHANGELOG.md). +API gotchas, and what's deliberately trimmed vs iOS. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5610255..9e20819 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -31,8 +31,7 @@ android { buildTypes { release { - isMinifyEnabled = true - isShrinkResources = true + isMinifyEnabled = false proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") } } @@ -52,6 +51,7 @@ dependencies { implementation("androidx.activity:activity-compose:1.9.2") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6") implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.6") + implementation("androidx.navigation:navigation-compose:2.8.1") implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1") diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 0ebe13d..744eec1 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -1,40 +1,4 @@ -# ─── kotlinx.serialization ─────────────────────────────────────────────────── -# R8 must not strip the generated $$serializer classes / serializer() methods or -# the @Serializable config (ApiConfiguration, ServerTrustPolicy) fails to (de)serialize. +# Keep kotlinx.serialization metadata. -keepattributes *Annotation*, InnerClasses -dontnote kotlinx.serialization.** -keepclassmembers class kotlinx.serialization.json.** { *; } - -# Keep `serializer()` and the synthetic Companion for every @Serializable type. --if @kotlinx.serialization.Serializable class ** --keepclassmembers class <1> { - static <1>$Companion Companion; -} --if @kotlinx.serialization.Serializable class ** { - static **$* *; -} --keepclassmembers class <2>$<3> { - kotlinx.serialization.KSerializer serializer(...); -} - -# Keep this app's serializable models and their generated serializers explicitly. --keep,includedescriptorclasses class no.svorka.mcms.**$$serializer { *; } --keepclassmembers class no.svorka.mcms.** { - *** Companion; -} --keepclasseswithmembers class no.svorka.mcms.** { - kotlinx.serialization.KSerializer serializer(...); -} - -# ─── OkHttp / Okio ─────────────────────────────────────────────────────────── -# OkHttp ships its own consumer rules; silence the optional-dependency warnings. --dontwarn okhttp3.** --dontwarn okio.** --dontwarn org.conscrypt.** --dontwarn org.bouncycastle.** --dontwarn org.openjsse.** - -# ─── Tink / EncryptedSharedPreferences (androidx.security-crypto) ───────────── -# Tink references compile-only Error Prone annotations that aren't on the runtime -# classpath; they're not needed at runtime, so silence the missing-class errors. --dontwarn com.google.errorprone.annotations.** diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a520361..66e4321 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,14 +5,12 @@ + android:theme="@style/Theme.MCMS"> ().state + val app = remember { AppState(applicationContext) } PonGoTheme { RootApp(app) } } } diff --git a/app/src/main/java/no/svorka/mcms/core/ApiClient.kt b/app/src/main/java/no/svorka/mcms/core/ApiClient.kt index 8c5946c..aa63470 100644 --- a/app/src/main/java/no/svorka/mcms/core/ApiClient.kt +++ b/app/src/main/java/no/svorka/mcms/core/ApiClient.kt @@ -1,9 +1,6 @@ package no.svorka.mcms.core -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.withContext import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.buildJsonObject @@ -20,19 +17,13 @@ import okhttp3.Response import java.io.IOException import java.util.concurrent.TimeUnit import javax.net.ssl.SSLException +import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException -private fun Response.closeQuietly() { runCatching { close() } } - /** Suspend bridge for an OkHttp call, with coroutine cancellation. */ -@OptIn(ExperimentalCoroutinesApi::class) // resume(value) { onCancellation } overload suspend fun Call.await(): Response = suspendCancellableCoroutine { cont -> enqueue(object : Callback { - override fun onResponse(call: Call, response: Response) { - // If the continuation was already cancelled, resume() is a no-op and the - // Response would otherwise leak — close it via the onCancellation handler. - cont.resume(response) { _ -> response.closeQuietly() } - } + override fun onResponse(call: Call, response: Response) { cont.resume(response) } override fun onFailure(call: Call, e: IOException) { if (!cont.isCancelled) cont.resumeWithException(e) } @@ -41,10 +32,9 @@ suspend fun Call.await(): Response = suspendCancellableCoroutine { cont -> } /** - * Transport for the MCMS REST API. Builds requests, injects auth headers, unwraps - * the `{status,data}` envelope, and maps status codes to [ApiError]. Returns raw - * [JsonElement] trees that the repositories wrap. Endpoint versions are fixed per - * endpoint in [McmsEndpoint.path]. + * Transport for the MCMS REST API. Builds versioned requests, injects auth + * headers, unwraps the `{status,data}` envelope, and maps status codes to + * [ApiError]. Returns raw [JsonElement] trees that the repositories wrap. */ class ApiClient( private val config: ApiConfiguration, @@ -61,30 +51,28 @@ class ApiClient( ServerTrust.apply(this, config.trustPolicy) }.build() - suspend fun getElement(endpoint: McmsEndpoint, query: ApiQuery = ApiQuery.EMPTY): JsonElement = - unwrapData(perform("GET", endpoint, query, null)) + suspend fun getElement(endpoint: McmsEndpoint, query: ApiQuery = ApiQuery.EMPTY, version: String? = null): JsonElement = + unwrapData(perform("GET", endpoint, query, null, version)) - suspend fun getList(endpoint: McmsEndpoint, query: ApiQuery = ApiQuery.EMPTY): List = - getElement(endpoint, query).array ?: emptyList() + suspend fun getList(endpoint: McmsEndpoint, query: ApiQuery = ApiQuery.EMPTY, version: String? = null): List = + getElement(endpoint, query, version).array ?: emptyList() /** Raw body, bypassing the envelope — for the bare-array CPE helper. */ - suspend fun getRaw(endpoint: McmsEndpoint): JsonElement { - val resp = perform("GET", endpoint, ApiQuery.EMPTY, null) - if (resp.body.isBlank()) return JsonNull - return runCatching { MCMSJson.parseToJsonElement(resp.body) } - .getOrElse { throw ApiError.Decoding(it.message ?: "invalid JSON in response") } + suspend fun getRaw(endpoint: McmsEndpoint, version: String? = null): JsonElement { + val resp = perform("GET", endpoint, ApiQuery.EMPTY, null, version) + return if (resp.body.isBlank()) JsonNull else MCMSJson.parseToJsonElement(resp.body) } - suspend fun send(method: String, endpoint: McmsEndpoint, body: JsonElement, wrapInData: Boolean = true): JsonElement { + suspend fun send(method: String, endpoint: McmsEndpoint, body: JsonElement, wrapInData: Boolean = true, version: String? = null): JsonElement { val payload = if (wrapInData) buildJsonObject { put("data", body) } else body - return unwrapData(perform(method, endpoint, ApiQuery.EMPTY, payload.toString())) + return unwrapData(perform(method, endpoint, ApiQuery.EMPTY, payload.toString(), version)) } - suspend fun action(method: String, endpoint: McmsEndpoint): JsonElement = - unwrapData(perform(method, endpoint, ApiQuery.EMPTY, null)) + suspend fun action(method: String, endpoint: McmsEndpoint, version: String? = null): JsonElement = + unwrapData(perform(method, endpoint, ApiQuery.EMPTY, null, version)) - suspend fun delete(endpoint: McmsEndpoint) { - perform("DELETE", endpoint, ApiQuery.EMPTY, null) + suspend fun delete(endpoint: McmsEndpoint, version: String? = null) { + perform("DELETE", endpoint, ApiQuery.EMPTY, null, version) } /** Paginates by the `next` cursor; page size 100 (field guide §3.4). */ @@ -92,37 +80,33 @@ class ApiClient( endpoint: McmsEndpoint, baseQuery: ApiQuery = ApiQuery.EMPTY, pageLimit: Int = 100, + version: String? = null, idOf: (JsonElement) -> String?, ): List { val all = mutableListOf() var cursor: String? = null do { val q = baseQuery.copy(limit = pageLimit, next = cursor) - val page = getList(endpoint, q) + val page = getList(endpoint, q, version) all += page - cursor = if (page.size == pageLimit) { - // A full page with no usable cursor would silently truncate the result — - // fail loudly instead of returning a partial fleet as if it were complete. - page.lastOrNull()?.let(idOf) - ?: throw ApiError.Decoding("pagination cursor missing on a full page; cannot guarantee a complete result") - } else null + cursor = if (page.size == pageLimit) page.lastOrNull()?.let(idOf) else null } while (cursor != null) return all } private data class Resp(val code: Int, val body: String) - private fun buildUrl(endpoint: McmsEndpoint, query: ApiQuery): HttpUrl { + private fun buildUrl(endpoint: McmsEndpoint, query: ApiQuery, version: String?): HttpUrl { val base = config.baseUrl.toHttpUrlOrNull() ?: throw ApiError.InvalidUrl val builder = base.newBuilder() // Raw ids; addPathSegments encodes once and keeps ':' in MAC ids raw. - builder.addPathSegments(endpoint.path().removePrefix("/")) + builder.addPathSegments(endpoint.path(version ?: config.apiVersion).removePrefix("/")) for ((name, value) in query.items()) builder.addQueryParameter(name, value) return builder.build() } - private suspend fun perform(method: String, endpoint: McmsEndpoint, query: ApiQuery, bodyJson: String?): Resp { - val url = buildUrl(endpoint, query) + private suspend fun perform(method: String, endpoint: McmsEndpoint, query: ApiQuery, bodyJson: String?, version: String?): Resp { + val url = buildUrl(endpoint, query, version) val reqBody = when { bodyJson != null -> bodyJson.toRequestBody(jsonMedia) method == "POST" || method == "PUT" || method == "PATCH" -> ByteArray(0).toRequestBody() @@ -130,26 +114,21 @@ class ApiClient( } val builder = Request.Builder().url(url) .header("Accept", "application/json") - .header("User-Agent", USER_AGENT) // §3.1: an empty UA is rejected .header("Referer", config.refererValue) .method(method, reqBody) if (bodyJson != null) builder.header("Content-Type", "application/json") if (method != "GET") session.csrfToken()?.let { builder.header("X-CSRFToken", it) } - val request = builder.build() - // The blocking socket read (and connect) runs off the main thread; the - // status mapping + onAuthExpired callback resume on the caller (Main) so the - // Compose-state write in onAuthExpired stays on the main thread. - val (code, body) = withContext(Dispatchers.IO) { - val response = try { - client.newCall(request).await() - } catch (e: SSLException) { - throw ApiError.ServerTrust - } catch (e: IOException) { - throw ApiError.Transport(e.message ?: "request failed") - } - response.use { it.code to (it.body?.string() ?: "") } + val response = try { + client.newCall(builder.build()).await() + } catch (e: SSLException) { + throw ApiError.ServerTrust + } catch (e: IOException) { + throw ApiError.Transport(e.message ?: "request failed") } + val code = response.code + val body = response.body?.string() ?: "" + response.close() if (code !in 200..299) { val details = runCatching { MCMSJson.parseToJsonElement(body)["details"] }.getOrNull() @@ -162,18 +141,11 @@ class ApiClient( private fun unwrapData(resp: Resp): JsonElement { if (resp.body.isBlank()) return JsonNull - val root = runCatching { MCMSJson.parseToJsonElement(resp.body) } - .getOrElse { throw ApiError.Decoding(it.message ?: "invalid JSON in response") } + val root = MCMSJson.parseToJsonElement(resp.body) val status = root["status"].string - // MCMS treats "warning" as success (the write committed; only a schema - // mismatch is reported in `details`) — see MCMS_API.md §3.5. - if (status != null && !status.equals("success", ignoreCase = true) && !status.equals("warning", ignoreCase = true)) { + if (status != null && !status.equals("success", ignoreCase = true)) { throw ApiError.ApiFailure(root["details"]) } return root["data"] ?: JsonNull } - - private companion object { - const val USER_AGENT = "PONGo-Android/1.0" - } } diff --git a/app/src/main/java/no/svorka/mcms/core/ApiConfiguration.kt b/app/src/main/java/no/svorka/mcms/core/ApiConfiguration.kt index 095e217..11d26ad 100644 --- a/app/src/main/java/no/svorka/mcms/core/ApiConfiguration.kt +++ b/app/src/main/java/no/svorka/mcms/core/ApiConfiguration.kt @@ -1,34 +1,33 @@ package no.svorka.mcms.core import kotlinx.serialization.Serializable -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import java.net.URI /** How to evaluate the server's TLS certificate. */ @Serializable sealed class ServerTrustPolicy { - /** Default platform trust evaluation (system CA store). The only default. */ + /** Default platform trust evaluation. */ @Serializable data object System : ServerTrustPolicy() - /** - * Trust only if the leaf public-key SHA-256 (base64) matches one of these. - * This is the secure way to talk to a known box with a private/self-signed - * cert: pin its key rather than disabling validation. No UI wires this up - * yet — set it programmatically if a deployment needs it. - */ + /** Accept any certificate presented by this exact host (self-signed lab boxes). */ + @Serializable + data class AllowSelfSignedForHost(val host: String) : ServerTrustPolicy() + + /** Trust only if the leaf public-key SHA-256 (base64) matches one of these. */ @Serializable data class PinPublicKeySha256(val pins: List, val host: String) : ServerTrustPolicy() } /** * Connection configuration for the MCMS REST API. URL-driven so the same build - * works direct or via reverse proxy. `baseUrl` includes the `/api` prefix and is - * always HTTPS, e.g. `https://mcms.lab.svorka.net/api`. + * works direct or via reverse proxy. `baseUrl` includes the `/api` prefix, e.g. + * `https://mcms.lab.svorka.net/api`. */ @Serializable data class ApiConfiguration( val baseUrl: String, + val apiVersion: String = "v1", val databaseId: String? = null, val refererOverride: String? = null, val sessionCookieName: String = "sessionid", @@ -37,36 +36,24 @@ data class ApiConfiguration( val timeoutSeconds: Long = 30, ) { val refererValue: String get() = refererOverride ?: baseUrl - - /** - * Host as OkHttp parses it — must match the cookie jar's `HttpUrl.host` so the - * CSRF/session cookie lookup in [SessionStore] resolves against the same key. - */ - val host: String? get() = baseUrl.toHttpUrlOrNull()?.host + val host: String? get() = runCatching { URI(baseUrl).host }.getOrNull() companion object { /** - * Normalises a user-entered URL and **enforces HTTPS**: defaults a missing - * scheme to `https`, rejects anything that isn't `https`, rejects embedded - * credentials, trims a trailing slash, and defaults the path to `/api` when - * none is given. Returns null if it isn't a usable HTTPS URL. + * Normalises a user-entered URL: requires scheme + host, trims a trailing + * slash, and defaults the path to `/api` when none is given. Returns null + * if it isn't a usable URL. */ fun normalizedBaseUrl(raw: String): String? { val trimmed = raw.trim() - if (trimmed.isEmpty()) return null - // Default to https:// when the user omits the scheme (e.g. "mcms.lab/api"). - val withScheme = - if (Regex("^[a-zA-Z][a-zA-Z0-9+.-]*://").containsMatchIn(trimmed)) trimmed - else "https://$trimmed" - val uri = runCatching { URI(withScheme) }.getOrNull() ?: return null - if (uri.scheme?.lowercase() != "https") return null // TLS only - if (uri.userInfo != null) return null // no credentials in the URL + val uri = runCatching { URI(trimmed) }.getOrNull() ?: return null + val scheme = uri.scheme ?: return null val host = uri.host?.takeIf { it.isNotEmpty() } ?: return null var path = uri.path ?: "" while (path.endsWith("/")) path = path.dropLast(1) if (path.isEmpty()) path = "/api" val port = if (uri.port >= 0) ":${uri.port}" else "" - return "https://$host$port$path" + return "$scheme://$host$port$path" } } } diff --git a/app/src/main/java/no/svorka/mcms/core/Firmware.kt b/app/src/main/java/no/svorka/mcms/core/Firmware.kt index bfd32f6..0870d7e 100644 --- a/app/src/main/java/no/svorka/mcms/core/Firmware.kt +++ b/app/src/main/java/no/svorka/mcms/core/Firmware.kt @@ -17,20 +17,12 @@ class FirmwareFile(val raw: JsonElement) { val sizeBytes: Double? get() = raw["length"].double val uploadDate: String? get() = raw["uploadDate"].string - /** - * Fail CLOSED: a file counts as compatible only when we can positively confirm - * it. The ONU's vendor and equipment id must be known, the file must declare a - * matching manufacturer, and the equipment id must be an exact (not substring) - * member of Compatible Model. Unknown identity or missing metadata ⇒ not - * compatible-by-default (such files are still reachable via "Show all firmware", - * but staging one requires an explicit extra acknowledgment in the UI). - * Mis-flashing the wrong image can brick an ONU, so we never guess. - */ + /** Manufacturer must match the ONU vendor and the equipment id must be an + * exact (not substring) member of Compatible Model. */ fun isCompatible(vendor: String?, equipmentId: String?): Boolean { - if (vendor == null || equipmentId == null) return false - val mfg = manufacturer ?: return false - if (!mfg.equals(vendor, ignoreCase = true)) return false - if (models.isEmpty()) return false + val mfg = manufacturer + if (vendor != null && !mfg.isNullOrEmpty() && !mfg.equals(vendor, ignoreCase = true)) return false + if (equipmentId == null || models.isEmpty()) return true return models.any { it.equals(equipmentId, ignoreCase = true) } } } diff --git a/app/src/main/java/no/svorka/mcms/core/McmsEndpoint.kt b/app/src/main/java/no/svorka/mcms/core/McmsEndpoint.kt index e25857b..c946794 100644 --- a/app/src/main/java/no/svorka/mcms/core/McmsEndpoint.kt +++ b/app/src/main/java/no/svorka/mcms/core/McmsEndpoint.kt @@ -5,10 +5,6 @@ package no.svorka.mcms.core * URL builder in [ApiClient] percent-encodes the whole path exactly once. * Pre-encoding double-encoded MAC colons (`:` → `%3A` → `%253A`), which MCMS * rejected as an invalid id. - * - * The `/v1` vs `/v3` prefix is fixed per endpoint per docs/MCMS_API.md §4 — the - * two versions serve different resources and are NOT interchangeable, so there is - * no global version switch. Endpoints not in the §4 table are marked "inferred". */ sealed class McmsEndpoint { data object Authenticate : McmsEndpoint() @@ -41,35 +37,38 @@ sealed class McmsEndpoint { data object OnuFirmwareFiles : McmsEndpoint() data class OnuCpe(val id: String) : McmsEndpoint() // versionless web helper - fun path(): String = when (this) { - Authenticate -> "/v1/users/authenticate/" // §4 - Logout -> "/v1/users/logout/" // §4 - DatabaseSelection -> "/v1/databases/selection/" // §4 - Version -> "/v1/ponmgr/version/" // inferred (probe) + fun path(version: String): String { + val v = "/$version" + return when (this) { + Authenticate -> "$v/users/authenticate/" + Logout -> "$v/users/logout/" + DatabaseSelection -> "$v/databases/selection/" + Version -> "$v/ponmgr/version/" - ControllerConfigs -> "/v1/controllers/configs/" // inferred (non-fatal, runCatching) - is ControllerConfig -> "/v1/controllers/configs/$id/" - is ControllerStats -> "/v1/controllers/stats/$id/" - is ControllerLogs -> "/v1/controllers/logs/$id/" + ControllerConfigs -> "$v/controllers/configs/" + is ControllerConfig -> "$v/controllers/configs/$id/" + is ControllerStats -> "$v/controllers/stats/$id/" + is ControllerLogs -> "$v/controllers/logs/$id/" - OltConfigs -> "/v3/olts/configs/" // inferred: configs-list family is v3 (cf. ONU) - is OltConfig -> "/v1/olts/configs/$id/" // inferred: single config is v1 (cf. ONU) - OltStates -> "/v3/olts/states/" // §4 - is OltState -> "/v3/olts/states/$id/" // inferred: states family is v3 - is OltStats -> "/v1/olts/stats/$id/" // §4 - is OltLogs -> "/v1/olts/logs/$id/" // §4 - is OltReset -> "/v1/olts/$id/reset/" // inferred (prior default) + OltConfigs -> "$v/olts/configs/" + is OltConfig -> "$v/olts/configs/$id/" + OltStates -> "$v/olts/states/" + is OltState -> "$v/olts/states/$id/" + is OltStats -> "$v/olts/stats/$id/" + is OltLogs -> "$v/olts/logs/$id/" + is OltReset -> "$v/olts/$id/reset/" - OnuConfigs -> "/v3/onus/configs/" // §4 - is OnuConfig -> "/v1/onus/configs/$id/" // §4 - OnuStates -> "/v3/onus/states/" // §4 - is OnuState -> "/v3/onus/states/$id/" // §4 - is OnuStats -> "/v1/onus/stats/$id/" // §4 - is OnuLogs -> "/v1/onus/logs/$id/" // §4 - OnuAlarmHistories -> "/v1/onus/alarm-histories/" // inferred (non-fatal, runCatching) - OnuCpeStates -> "/v1/onus/cpe-states/" // §4 - is OnuReset -> "/v1/onus/$id/reset/" // inferred (prior default) - OnuFirmwareFiles -> "/v1/files/onu-firmware/" // §4 - is OnuCpe -> "/cpe/onu/$id/" // versionless web helper + OnuConfigs -> "$v/onus/configs/" + is OnuConfig -> "$v/onus/configs/$id/" + OnuStates -> "$v/onus/states/" + is OnuState -> "$v/onus/states/$id/" + is OnuStats -> "$v/onus/stats/$id/" + is OnuLogs -> "$v/onus/logs/$id/" + OnuAlarmHistories -> "$v/onus/alarm-histories/" + OnuCpeStates -> "$v/onus/cpe-states/" + is OnuReset -> "$v/onus/$id/reset/" + OnuFirmwareFiles -> "$v/files/onu-firmware/" + is OnuCpe -> "/cpe/onu/$id/" + } } } diff --git a/app/src/main/java/no/svorka/mcms/core/Networking.kt b/app/src/main/java/no/svorka/mcms/core/Networking.kt index 68965df..67c2402 100644 --- a/app/src/main/java/no/svorka/mcms/core/Networking.kt +++ b/app/src/main/java/no/svorka/mcms/core/Networking.kt @@ -4,18 +4,18 @@ import okhttp3.Cookie import okhttp3.CookieJar import okhttp3.HttpUrl import okhttp3.OkHttpClient +import java.security.SecureRandom +import java.security.cert.X509Certificate +import javax.net.ssl.SSLContext +import javax.net.ssl.X509TrustManager /** * In-memory cookie jar (per host), so we can both replay cookies and read the * CSRF cookie value. Sessions are discarded on logout/app-kill. - * - * OkHttp invokes the jar from its background dispatcher threads, so every access - * to [store] is synchronised on this instance. */ class SessionCookieJar : CookieJar { private val store = mutableMapOf>() - @Synchronized override fun saveFromResponse(url: HttpUrl, cookies: List) { val list = store.getOrPut(url.host) { mutableListOf() } cookies.forEach { c -> @@ -24,19 +24,15 @@ class SessionCookieJar : CookieJar { } } - @Synchronized override fun loadForRequest(url: HttpUrl): List = store[url.host]?.filter { it.matches(url) } ?: emptyList() - @Synchronized fun valueMatching(host: String, base: String): String? = store[host]?.firstOrNull { matches(it.name, base) }?.value - @Synchronized fun hasCookie(host: String, base: String): Boolean = store[host]?.any { matches(it.name, base) } == true - @Synchronized fun clear(host: String) { store.remove(host) } // Modern MCMS builds emit `__Host-`/`__Secure-` prefixed cookies; accept either. @@ -68,9 +64,21 @@ class SessionStore(private val config: ApiConfiguration) { object ServerTrust { fun apply(builder: OkHttpClient.Builder, policy: ServerTrustPolicy) { when (policy) { - // Default platform trust (system CA store). Untrusted/self-signed - // certificates are intentionally NOT accepted — pin instead. - is ServerTrustPolicy.System -> Unit + is ServerTrustPolicy.System -> Unit // default platform trust + is ServerTrustPolicy.AllowSelfSignedForHost -> { + // Accept ANY certificate, but the hostname verifier restricts it to + // the one allowed host (matches the iOS self-signed escape hatch). + val trustAll = object : X509TrustManager { + override fun checkClientTrusted(chain: Array?, authType: String?) {} + override fun checkServerTrusted(chain: Array?, authType: String?) {} + override fun getAcceptedIssuers(): Array = emptyArray() + } + val ctx = SSLContext.getInstance("TLS").apply { + init(null, arrayOf(trustAll), SecureRandom()) + } + builder.sslSocketFactory(ctx.socketFactory, trustAll) + builder.hostnameVerifier { hostname, _ -> hostname.equals(policy.host, ignoreCase = true) } + } is ServerTrustPolicy.PinPublicKeySha256 -> { val pinner = okhttp3.CertificatePinner.Builder().apply { policy.pins.forEach { add(policy.host, "sha256/$it") } diff --git a/app/src/main/java/no/svorka/mcms/ui/AppState.kt b/app/src/main/java/no/svorka/mcms/ui/AppState.kt index 7385915..6121922 100644 --- a/app/src/main/java/no/svorka/mcms/ui/AppState.kt +++ b/app/src/main/java/no/svorka/mcms/ui/AppState.kt @@ -50,19 +50,20 @@ class AppState(context: Context) { } val savedEmail: String? get() = keychain.get("email") + val savedPassword: String? get() = keychain.get("password") suspend fun signIn(email: String, password: String, remember: Boolean) { val conn = connection ?: throw ApiError.Transport("No connection configured") conn.auth.login(email, password) - // Remember-me persists the email only; the password is never stored at rest. - if (remember) keychain.set("email", email) else keychain.delete("email") - keychain.delete("password") // clear any password persisted by an older build + if (remember) { + keychain.set("email", email) + keychain.set("password", password) + } isAuthenticated = true } suspend fun signOut() { - runCatching { connection?.auth?.logout() } - keychain.delete("password") // defence-in-depth: never leave a password behind + connection?.auth?.logout() isAuthenticated = false } diff --git a/app/src/main/java/no/svorka/mcms/ui/AuthScreens.kt b/app/src/main/java/no/svorka/mcms/ui/AuthScreens.kt index 3a42818..7c559bc 100644 --- a/app/src/main/java/no/svorka/mcms/ui/AuthScreens.kt +++ b/app/src/main/java/no/svorka/mcms/ui/AuthScreens.kt @@ -16,14 +16,13 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch import no.svorka.mcms.core.ApiConfiguration +import no.svorka.mcms.core.ServerTrustPolicy @OptIn(ExperimentalMaterial3Api::class) @Composable fun LoginScreen(app: AppState, onOpenSettings: () -> Unit) { var email by rememberSaveable { mutableStateOf(app.savedEmail ?: "") } - // Plain remember (not rememberSaveable) and never pre-filled: keep the password - // out of the on-disk saved-instance Bundle and off a potentially shared screen. - var password by remember { mutableStateOf("") } + var password by rememberSaveable { mutableStateOf(app.savedPassword ?: "") } var remember by rememberSaveable { mutableStateOf(true) } var phase by remember { mutableStateOf(LoadPhase.Idle) } val scope = rememberCoroutineScope() @@ -80,7 +79,11 @@ fun LoginScreen(app: AppState, onOpenSettings: () -> Unit) { @Composable fun SettingsScreen(app: AppState, initialSetup: Boolean, onClose: () -> Unit) { var url by rememberSaveable { mutableStateOf(app.configuration?.baseUrl ?: "https://") } + var apiVersion by rememberSaveable { mutableStateOf(app.configuration?.apiVersion ?: "v1") } var dbId by rememberSaveable { mutableStateOf(app.configuration?.databaseId ?: "") } + var allowSelfSigned by rememberSaveable { + mutableStateOf(app.configuration?.trustPolicy is ServerTrustPolicy.AllowSelfSignedForHost) + } var testPhase by remember { mutableStateOf(LoadPhase.Idle) } var testResult by remember { mutableStateOf(null) } var saveError by remember { mutableStateOf(null) } @@ -88,9 +91,15 @@ fun SettingsScreen(app: AppState, initialSetup: Boolean, onClose: () -> Unit) { fun makeConfig(): ApiConfiguration? { val base = ApiConfiguration.normalizedBaseUrl(url) ?: return null - return ApiConfiguration(baseUrl = base, databaseId = dbId.ifBlank { null }) + val host = ApiConfiguration(baseUrl = base).host + val policy = if (allowSelfSigned && host != null) ServerTrustPolicy.AllowSelfSignedForHost(host) + else ServerTrustPolicy.System + return ApiConfiguration( + baseUrl = base, apiVersion = apiVersion.trim(), + databaseId = dbId.ifBlank { null }, trustPolicy = policy, + ) } - val canSave = ApiConfiguration.normalizedBaseUrl(url) != null + val canSave = ApiConfiguration.normalizedBaseUrl(url) != null && apiVersion.isNotBlank() Scaffold(topBar = { TopAppBar( @@ -101,7 +110,7 @@ fun SettingsScreen(app: AppState, initialSetup: Boolean, onClose: () -> Unit) { enabled = canSave, onClick = { val config = makeConfig() - if (config == null) { saveError = "Enter a valid https:// URL." } + if (config == null) { saveError = "Enter a valid URL including scheme (https://)." } else { saveError = null; app.configure(config); if (!initialSetup) onClose() } }, ) { Text("Save") } @@ -113,15 +122,24 @@ fun SettingsScreen(app: AppState, initialSetup: Boolean, onClose: () -> Unit) { verticalArrangement = Arrangement.spacedBy(12.dp), ) { OutlinedTextField( - value = url, onValueChange = { url = it }, label = { Text("Server URL (HTTPS)") }, singleLine = true, - supportingText = { Text("Host or origin, e.g. mcms.lab.svorka.net — HTTPS is required and “/api” is appended automatically.") }, + value = url, onValueChange = { url = it }, label = { Text("Server URL") }, singleLine = true, + supportingText = { Text("Host or origin, e.g. https://mcms.lab.svorka.net — “/api” is appended automatically.") }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), modifier = Modifier.fillMaxWidth(), ) + OutlinedTextField( + value = apiVersion, onValueChange = { apiVersion = it }, label = { Text("API version") }, singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) OutlinedTextField( value = dbId, onValueChange = { dbId = it }, label = { Text("Database ID (optional)") }, singleLine = true, modifier = Modifier.fillMaxWidth(), ) + Row(verticalAlignment = Alignment.CenterVertically) { + Switch(checked = allowSelfSigned, onCheckedChange = { allowSelfSigned = it }) + Spacer(Modifier.width(8.dp)) + Text("Allow self-signed certificate") + } Button( enabled = canSave && testPhase != LoadPhase.Loading, diff --git a/app/src/main/java/no/svorka/mcms/ui/DashboardScreen.kt b/app/src/main/java/no/svorka/mcms/ui/DashboardScreen.kt index 0881e01..d7d2c30 100644 --- a/app/src/main/java/no/svorka/mcms/ui/DashboardScreen.kt +++ b/app/src/main/java/no/svorka/mcms/ui/DashboardScreen.kt @@ -15,7 +15,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay import no.svorka.mcms.core.* @@ -32,46 +31,30 @@ class DashboardModel(private val connection: McmsConnection) { var lasersOff by mutableStateOf>(emptyList()) var opticalAvailable by mutableStateOf(false) - // Monotonic load id: a slower earlier load must not overwrite a newer one's data. - private var loadGen = 0 - suspend fun load(extraStats: Boolean) { - val gen = ++loadGen phase = LoadPhase.Loading try { - // Fetch into locals, then commit atomically once we confirm we're still current. val olts = connection.olt.allStates() - val reg = OltRepository.registrationMap(olts) - val counts = reg.values.groupingBy { it }.eachCount().toList().sortedByDescending { it.second } - val down = olts.mapNotNull { it.txRateBps }.sum() - val up = olts.mapNotNull { it.rxRateBps }.sum() - val lasers = olts.filter { it.isLaserOff } - val ctrl = runCatching { connection.controller.allConfigs().size }.getOrDefault(0) - val alarms = runCatching { connection.onu.alarmHistories() }.getOrDefault(emptyList()) - val sev = alarms.groupingBy { it.severity }.eachCount().toList().sortedBy { it.first.rank } - val (optAvail, abnormal) = if (extraStats) { - val onus = connection.onu.allStates() - onus.any { it.rxOpticalDBm != null } to - onus.filter { val rx = it.rxOpticalDBm; rx != null && (rx < -28 || rx > -10) } - } else { - false to emptyList() - } - if (gen != loadGen) return // a newer load superseded us — drop stale results oltCount = olts.size + val reg = OltRepository.registrationMap(olts) onuTotal = reg.size - onuCounts = counts - totalDownBps = down - totalUpBps = up - lasersOff = lasers - controllerCount = ctrl - severityCounts = sev - opticalAvailable = optAvail - abnormalRx = abnormal + onuCounts = reg.values.groupingBy { it }.eachCount().toList().sortedByDescending { it.second } + totalDownBps = olts.mapNotNull { it.txRateBps }.sum() + totalUpBps = olts.mapNotNull { it.rxRateBps }.sum() + lasersOff = olts.filter { it.isLaserOff } + controllerCount = runCatching { connection.controller.allConfigs().size }.getOrDefault(0) + val alarms = runCatching { connection.onu.alarmHistories() }.getOrDefault(emptyList()) + severityCounts = alarms.groupingBy { it.severity }.eachCount().toList().sortedBy { it.first.rank } + if (extraStats) { + val onus = connection.onu.allStates() + opticalAvailable = onus.any { it.rxOpticalDBm != null } + abnormalRx = onus.filter { val rx = it.rxOpticalDBm; rx != null && (rx < -28 || rx > -10) } + } else { + opticalAvailable = false; abnormalRx = emptyList() + } phase = LoadPhase.Loaded - } catch (e: CancellationException) { - throw e // cancellation is not a failure } catch (e: Throwable) { - if (gen == loadGen) phase = LoadPhase.Failed(e.uiMessage()) + phase = LoadPhase.Failed(e.uiMessage()) } } } @@ -119,8 +102,7 @@ fun DashboardScreen( Box(Modifier.padding(padding).fillMaxSize()) { when (val p = model.phase) { is LoadPhase.Idle, is LoadPhase.Loading -> if (model.onuTotal == 0 && model.oltCount == 0) LoadingView("Loading network…") else content(model, openAbnormalRx, openLaserOff) - // Keep showing the last good data if a background refresh fails. - is LoadPhase.Failed -> if (model.onuTotal == 0 && model.oltCount == 0) ErrorState(p.message) else content(model, openAbnormalRx, openLaserOff) + is LoadPhase.Failed -> ErrorState(p.message) is LoadPhase.Loaded -> content(model, openAbnormalRx, openLaserOff) } } diff --git a/app/src/main/java/no/svorka/mcms/ui/OltScreens.kt b/app/src/main/java/no/svorka/mcms/ui/OltScreens.kt index b788693..5444240 100644 --- a/app/src/main/java/no/svorka/mcms/ui/OltScreens.kt +++ b/app/src/main/java/no/svorka/mcms/ui/OltScreens.kt @@ -12,7 +12,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch import no.svorka.mcms.core.* @@ -28,23 +27,16 @@ class OltListModel(private val connection: McmsConnection) { return olts.filter { it.id.contains(q, true) || (it.resolvedName?.contains(q, true) == true) } } - private var loadGen = 0 - suspend fun load() { - val gen = ++loadGen phase = LoadPhase.Loading try { val fetched = connection.olt.allStates() val names = runCatching { connection.olt.nameMap() }.getOrDefault(emptyMap()) fetched.forEach { it.resolvedName = names[it.id] } - val sorted = fetched.sortedBy { it.displayName.lowercase() } - if (gen != loadGen) return - olts = sorted + olts = fetched.sortedBy { it.displayName.lowercase() } phase = LoadPhase.Loaded - } catch (e: CancellationException) { - throw e } catch (e: Throwable) { - if (gen == loadGen) phase = LoadPhase.Failed(e.uiMessage()) + phase = LoadPhase.Failed(e.uiMessage()) } } } @@ -53,7 +45,6 @@ class OltListModel(private val connection: McmsConnection) { @Composable fun OltListScreen(connection: McmsConnection, openOlt: (String, String) -> Unit) { val model = remember(connection) { OltListModel(connection) } - val scope = rememberCoroutineScope() LaunchedEffect(connection) { if (model.phase is LoadPhase.Idle) model.load() } Scaffold(topBar = { TopAppBar(title = { Text("OLTs") }) }) { padding -> @@ -65,7 +56,7 @@ fun OltListScreen(connection: McmsConnection, openOlt: (String, String) -> Unit) ) when (val p = model.phase) { is LoadPhase.Idle, is LoadPhase.Loading -> if (model.olts.isEmpty()) LoadingView("Loading OLTs…") else OltList(model.filtered, openOlt) - is LoadPhase.Failed -> ErrorState(p.message) { scope.launch { model.load() } } + is LoadPhase.Failed -> ErrorState(p.message) is LoadPhase.Loaded -> OltList(model.filtered, openOlt) } } @@ -101,30 +92,16 @@ class OltDetailModel(private val connection: McmsConnection, val oltId: String) var actionMessage by mutableStateOf(null) var busy by mutableStateOf(false) - private var loadGen = 0 - suspend fun load() { - val gen = ++loadGen phase = LoadPhase.Loading - try { - val v = connection.olt.state(oltId) - if (gen != loadGen) return - olt = v - phase = LoadPhase.Loaded - } catch (e: CancellationException) { - throw e - } catch (e: Throwable) { - if (gen == loadGen) phase = LoadPhase.Failed(e.uiMessage()) - } + try { olt = connection.olt.state(oltId); phase = LoadPhase.Loaded } + catch (e: Throwable) { phase = LoadPhase.Failed(e.uiMessage()) } } suspend fun reset() { - if (busy) return busy = true; actionMessage = null - try { connection.olt.reset(oltId); actionMessage = "Reset requested." } - catch (e: CancellationException) { throw e } - catch (e: Throwable) { actionMessage = e.uiMessage() } - finally { busy = false } + try { connection.olt.reset(oltId); actionMessage = "Reset requested." } catch (e: Throwable) { actionMessage = e.uiMessage() } + busy = false } } diff --git a/app/src/main/java/no/svorka/mcms/ui/OnuScreens.kt b/app/src/main/java/no/svorka/mcms/ui/OnuScreens.kt index 5bbe61d..1af94b2 100644 --- a/app/src/main/java/no/svorka/mcms/ui/OnuScreens.kt +++ b/app/src/main/java/no/svorka/mcms/ui/OnuScreens.kt @@ -16,7 +16,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement @@ -40,24 +39,17 @@ class OnuListModel(private val connection: McmsConnection) { } } - private var loadGen = 0 - suspend fun load() { - val gen = ++loadGen phase = LoadPhase.Loading try { val fetched = connection.onu.allStates() val reg = runCatching { connection.olt.registrationMap() }.getOrDefault(emptyMap()) val names = runCatching { connection.onu.nameMap() }.getOrDefault(emptyMap()) fetched.forEach { it.resolvedLifecycle = reg[it.id]; it.resolvedName = names[it.id] } - val sorted = fetched.sortedBy { it.displayName.lowercase() } - if (gen != loadGen) return - onus = sorted + onus = fetched.sortedBy { it.displayName.lowercase() } phase = LoadPhase.Loaded - } catch (e: CancellationException) { - throw e } catch (e: Throwable) { - if (gen == loadGen) phase = LoadPhase.Failed(e.uiMessage()) + phase = LoadPhase.Failed(e.uiMessage()) } } } @@ -66,7 +58,6 @@ class OnuListModel(private val connection: McmsConnection) { @Composable fun OnuListScreen(connection: McmsConnection, openOnu: (String) -> Unit) { val model = remember(connection) { OnuListModel(connection) } - val scope = rememberCoroutineScope() LaunchedEffect(connection) { if (model.phase is LoadPhase.Idle) model.load() } Scaffold(topBar = { TopAppBar(title = { Text("ONUs") }) }) { padding -> @@ -78,7 +69,7 @@ fun OnuListScreen(connection: McmsConnection, openOnu: (String) -> Unit) { ) when (val p = model.phase) { is LoadPhase.Idle, is LoadPhase.Loading -> if (model.onus.isEmpty()) LoadingView("Loading ONUs…") else OnuList(model.filtered, openOnu) - is LoadPhase.Failed -> ErrorState(p.message) { scope.launch { model.load() } } + is LoadPhase.Failed -> ErrorState(p.message) { /* retry */ } is LoadPhase.Loaded -> OnuList(model.filtered, openOnu) } } @@ -125,56 +116,40 @@ class OnuDetailModel(private val connection: McmsConnection, val onuId: String) var actionMessage by mutableStateOf(null) var busy by mutableStateOf(false) - // Monotonic load id so a slow fetch for a tab the user already left can't - // clobber the newer tab's phase/data. - private var loadGen = 0 - suspend fun loadTab() { phase = LoadPhase.Loading; reload() } suspend fun refresh() { reload() } private suspend fun reload() { - val gen = ++loadGen try { when (tab) { - 0 -> { val v = connection.onu.state(onuId); if (gen != loadGen) return; liveState = v } - 1 -> { val v = connection.onu.cpes(onuId); if (gen != loadGen) return; cpes = v } - 2 -> { val v = connection.onu.logs(onuId); if (gen != loadGen) return; logs = v } - 3 -> { val v = connection.onu.config(onuId); if (gen != loadGen) return; config = v } + 0 -> liveState = connection.onu.state(onuId) + 1 -> cpes = connection.onu.cpes(onuId) + 2 -> logs = connection.onu.logs(onuId) + 3 -> config = connection.onu.config(onuId) } - if (gen != loadGen) return phase = LoadPhase.Loaded - } catch (e: CancellationException) { - throw e } catch (e: Throwable) { - if (gen == loadGen) phase = LoadPhase.Failed(e.uiMessage()) + phase = LoadPhase.Failed(e.uiMessage()) } } suspend fun loadFirmware() { firmwareError = null - try { firmwareFiles = connection.onu.firmwareFiles() } - catch (e: CancellationException) { throw e } - catch (e: Throwable) { firmwareError = e.uiMessage() } + try { firmwareFiles = connection.onu.firmwareFiles() } catch (e: Throwable) { firmwareError = e.uiMessage() } } suspend fun reset() { - if (busy) return busy = true; actionMessage = null - try { connection.onu.reset(onuId); actionMessage = "Reset requested." } - catch (e: CancellationException) { throw e } - catch (e: Throwable) { actionMessage = e.uiMessage() } - finally { busy = false } + try { connection.onu.reset(onuId); actionMessage = "Reset requested." } catch (e: Throwable) { actionMessage = e.uiMessage() } + busy = false } suspend fun upgrade(file: FirmwareFile) { - if (busy) return busy = true; actionMessage = null try { val slot = connection.onu.upgradeFirmware(onuId, file) actionMessage = "Firmware ${file.version ?: "image"} staged to bank $slot. The ONU starts it after its next reboot." - } - catch (e: CancellationException) { throw e } - catch (e: Throwable) { actionMessage = e.uiMessage() } - finally { busy = false } + } catch (e: Throwable) { actionMessage = e.uiMessage() } + busy = false } } @@ -463,32 +438,12 @@ private fun FirmwareDialog(model: OnuDetailModel, onDismiss: () -> Unit) { ) pending?.let { file -> - val isCompatible = file.isCompatible(state?.vendor, state?.equipmentId) - var ack by remember(file) { mutableStateOf(false) } AlertDialog( onDismissRequest = { pending = null }, title = { Text("Stage firmware?") }, - text = { - Column { - Text("Stage ${file.version ?: "this image"} on ${model.onuId} in the standby bank. It becomes active after the ONU's next reboot — this is service-affecting.") - if (!isCompatible) { - Spacer(Modifier.height(12.dp)) - Text( - "⚠ This image is not confirmed compatible with this ONU (vendor/model mismatch or missing metadata). Flashing an incompatible image can leave the ONU unusable.", - color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall, - ) - Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox(checked = ack, onCheckedChange = { ack = it }) - Text("I understand this image may be incompatible") - } - } - } - }, + text = { Text("Stage ${file.version ?: "this image"} on ${model.onuId} in the standby bank. It becomes active after the ONU's next reboot — this is service-affecting.") }, confirmButton = { - TextButton( - enabled = isCompatible || ack, - onClick = { pending = null; onDismiss(); scope.launch { model.upgrade(file) } }, - ) { + TextButton(onClick = { pending = null; onDismiss(); scope.launch { model.upgrade(file) } }) { Text("Download ${file.version ?: "firmware"}", color = MaterialTheme.colorScheme.error) } }, diff --git a/app/src/main/java/no/svorka/mcms/ui/Types.kt b/app/src/main/java/no/svorka/mcms/ui/Types.kt index fa17dd8..1f1cd80 100644 --- a/app/src/main/java/no/svorka/mcms/ui/Types.kt +++ b/app/src/main/java/no/svorka/mcms/ui/Types.kt @@ -2,7 +2,6 @@ package no.svorka.mcms.ui import androidx.compose.ui.graphics.Color import no.svorka.mcms.core.AlarmSeverity -import no.svorka.mcms.core.ApiError import no.svorka.mcms.core.OnuLifecycleState /** Screen load state. */ @@ -32,9 +31,5 @@ fun AlarmSeverity.color(): Color = when (this) { AlarmSeverity.UNKNOWN -> Color(0xFF9E9E9E) } -/** - * Maps a thrown error to a user message. [ApiError] messages are written to be - * user-facing; anything else is unexpected and could leak library internals, so - * it collapses to a generic line. - */ -fun Throwable.uiMessage(): String = (this as? ApiError)?.message ?: "Something went wrong. Please try again." +/** Maps a thrown error to a user message. */ +fun Throwable.uiMessage(): String = message ?: "Something went wrong" diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml deleted file mode 100644 index bc81956..0000000 --- a/app/src/main/res/xml/network_security_config.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - diff --git a/dist/PONGo-1.0-release-unsigned.apk b/dist/PONGo-1.0-release-unsigned.apk deleted file mode 100644 index 71fd589..0000000 Binary files a/dist/PONGo-1.0-release-unsigned.apk and /dev/null differ