Security hardening + code-review fixes

Outcome of a full multi-agent code review. Touches TLS, credential storage,
API versioning, networking correctness, concurrency/lifecycle, and the release
build. See CHANGELOG.md for the full list and the items to port to the iOS app.

Security
- Remove untrusted-TLS support: delete ServerTrustPolicy.AllowSelfSignedForHost
  and its trust-all X509TrustManager; drop the self-signed Settings toggle.
- Enforce HTTPS in normalizedBaseUrl (reject http + embedded credentials);
  add network_security_config (no cleartext, system CAs only) + allowBackup=false.
- Never persist passwords at rest: remember-me stores email only; no password
  pre-fill; clear creds on sign-out / when remember-me is off.
- Match CSRF host lookup to OkHttp's host parsing; stop leaking raw exception text.

Correctness
- Fix API versioning: hardcode v1/v3 per endpoint (docs Sec.4) instead of one
  broken global apiVersion; remove the version plumbing + Settings field.
- Treat "warning" status as success (committed write); fail-fast on a missing
  pagination cursor; close Response on cancellation; tolerant JSON parse; User-Agent.
- Firmware isCompatible fails closed + extra acknowledgment for a non-compatible image.

Concurrency / lifecycle
- Run network I/O on Dispatchers.IO; stale-result generation guards + rethrow
  CancellationException in all loads; host AppState in a ViewModel (survive config
  change); thread-safe cookie jar; reset/upgrade re-entry guard; wire list Retry.

Build
- Enable R8 minify+shrink with kotlinx.serialization keep rules; drop unused
  navigation-compose; add the missing .gitignore.

NOTE: built/verified by static review only (no Android SDK on the dev machine) —
run :app:assembleDebug and a release build before shipping.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jon Vanvik 2026-05-31 17:37:48 +02:00
parent ca3d7d05e0
commit 41098831ae
20 changed files with 505 additions and 191 deletions

25
.gitignore vendored Normal file
View file

@ -0,0 +1,25 @@
# 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/

103
CHANGELOG.md Normal file
View file

@ -0,0 +1,103 @@
# 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.

View file

@ -37,7 +37,9 @@ everything. Two loops:
``` ```
Needs `local.properties` (`sdk.dir=…`, git-ignored). First run downloads deps Needs `local.properties` (`sdk.dir=…`, git-ignored). First run downloads deps
to `~/.gradle`; afterwards it's ~2060 s. This is what proves the whole app to `~/.gradle`; afterwards it's ~2060 s. This is what proves the whole app
(UI + resources + manifest + icons) compiles. (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.
- **Quick core-only check (no Gradle), when iterating on `core/`:** the bundled - **Quick core-only check (no Gradle), when iterating on `core/`:** the bundled
compiler at `…/Android Studio.app/Contents/plugins/Kotlin/kotlinc/bin/kotlinc` compiler at `…/Android Studio.app/Contents/plugins/Kotlin/kotlinc/bin/kotlinc`
with `-Xplugin=…/kotlinc/lib/kotlinx-serialization-compiler-plugin.jar` and a with `-Xplugin=…/kotlinc/lib/kotlinx-serialization-compiler-plugin.jar` and a
@ -50,16 +52,24 @@ everything. Two loops:
- **Single-encode URLs.** Ids stay RAW in `McmsEndpoint`; `ApiClient` encodes the - **Single-encode URLs.** Ids stay RAW in `McmsEndpoint`; `ApiClient` encodes the
path once via `HttpUrl.addPathSegments`. Pre-encoding double-encoded MAC colons path once via `HttpUrl.addPathSegments`. Pre-encoding double-encoded MAC colons
(`:``%3A``%253A`) → MCMS "invalid id". (`:``%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` - **401 bounces to login; 403 does NOT** (permission/CSRF — `requiresReauthentication`
is 401-only). is 401-only).
- **Writes return `{"status":"success"}` with no `data`** — the unwrap tolerates it. - **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).
- **ONU registration lives on OLT-STATE buckets** (`OltRepository.registrationMap`), - **ONU registration lives on OLT-STATE buckets** (`OltRepository.registrationMap`),
not ONU-STATE. Friendly names are `ONU.Name` / `OLT.Name` (NOT `NETCONF.Name`). not ONU-STATE. Friendly names are `ONU.Name` / `OLT.Name` (NOT `NETCONF.Name`).
- **CPE DHCP** comes from the web helper `GET /api/cpe/onu/<id>/` — a versionless, - **CPE DHCP** comes from the web helper `GET /api/cpe/onu/<id>/` — a versionless,
bare `[code, cpe…]` array (use `ApiClient.getRaw`). bare `[code, cpe…]` array (use `ApiClient.getRaw`).
- **Firmware** = Procedure 7: GET ONU-CFG → write filename/version into the - **Firmware** = Procedure 7: GET ONU-CFG → write filename/version into the
**inactive** bank → full-doc PUT (`FirmwareUpgrade.apply`). Inactive-bank only, **inactive** bank → full-doc PUT (`FirmwareUpgrade.apply`). Inactive-bank only,
so service stays up until the ONU reboots. Still worth testing on a spare ONU. 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.
- Field paths were confirmed against live JSON; see `docs/MCMS_API.md` and the - Field paths were confirmed against live JSON; see `docs/MCMS_API.md` and the
model accessors. model accessors.
@ -73,9 +83,11 @@ everything. Two loops:
drills ONU-state → filtered ONU list. drills ONU-state → filtered ONU list.
- Toggles use `rememberSaveable` (survive rotation, not process death). Persist in - Toggles use `rememberSaveable` (survive rotation, not process death). Persist in
prefs if you want parity with iOS `@AppStorage`. prefs if you want parity with iOS `@AppStorage`.
- Self-signed/cleartext: the TLS trust policy is in `ServerTrust`. Plain-HTTP hosts - **TLS is HTTPS-only with system-CA trust; self-signed/cleartext support was removed**
also need a `usesCleartextTraffic`/network-security-config exception (Android's (security — see `CHANGELOG.md`, not "easy to restore"). `res/xml/network_security_config.xml`
ATS analogue) — add when needed. 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`.
## Repo split ## Repo split

View file

@ -20,8 +20,9 @@ compileSdk 36, minSdk 26, JBR 21**.
## What it does (feature parity with iOS) ## What it does (feature parity with iOS)
- **Login / Settings** — URL normalisation (`/api` auto-appended), self-signed - **Login / Settings** — HTTPS-only URL normalisation (`/api` auto-appended, `http`
TLS toggle, test-connection probe, remember-me (Android Keystore), sign out. rejected), test-connection probe, remember-me (**email only** — passwords are never
stored, Android Keystore), sign out.
- **Dashboard** — ONU/OLT/controller counts; total OLT traffic; a **Health** - **Dashboard** — ONU/OLT/controller counts; total OLT traffic; a **Health**
section (abnormal-Rx ONUs, laser-off OLTs) that drills into the affected section (abnormal-Rx ONUs, laser-off OLTs) that drills into the affected
devices; ONU-state breakdown; **Detailed stats** (opt-in Rx scan) and devices; ONU-state breakdown; **Detailed stats** (opt-in Rx scan) and
@ -51,4 +52,6 @@ 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 See [`HANDOFF.md`](HANDOFF.md) for architecture notes, the build/verify loop, the
API gotchas, and what's deliberately trimmed vs iOS. 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).

View file

@ -31,7 +31,8 @@ android {
buildTypes { buildTypes {
release { release {
isMinifyEnabled = false isMinifyEnabled = true
isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
} }
} }
@ -51,7 +52,6 @@ dependencies {
implementation("androidx.activity:activity-compose:1.9.2") implementation("androidx.activity:activity-compose:1.9.2")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6")
implementation("androidx.lifecycle:lifecycle-runtime-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("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1")

View file

@ -1,4 +1,35 @@
# Keep kotlinx.serialization metadata. # ─── kotlinx.serialization ───────────────────────────────────────────────────
# R8 must not strip the generated $$serializer classes / serializer() methods or
# the @Serializable config (ApiConfiguration, ServerTrustPolicy) fails to (de)serialize.
-keepattributes *Annotation*, InnerClasses -keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.** -dontnote kotlinx.serialization.**
-keepclassmembers class kotlinx.serialization.json.** { *; } -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.**

View file

@ -5,12 +5,14 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application <application
android:allowBackup="true" android:allowBackup="false"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name" android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.MCMS"> android:theme="@style/Theme.MCMS"
android:usesCleartextTraffic="false">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"

View file

@ -1,9 +1,12 @@
package no.svorka.mcms package no.svorka.mcms
import android.app.Application
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@ -28,11 +31,20 @@ import no.svorka.mcms.core.OltStateDoc
import no.svorka.mcms.core.OnuStateDoc import no.svorka.mcms.core.OnuStateDoc
import no.svorka.mcms.ui.* import no.svorka.mcms.ui.*
/**
* Holds [AppState] in a ViewModel so it (and its in-memory session/cookie jar)
* survives configuration changes rotation/dark-mode no longer rebuilds the
* connection and bounces the user back to the login screen.
*/
class AppViewModel(application: Application) : AndroidViewModel(application) {
val state = AppState(application.applicationContext)
}
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContent { setContent {
val app = remember { AppState(applicationContext) } val app = viewModel<AppViewModel>().state
PonGoTheme { RootApp(app) } PonGoTheme { RootApp(app) }
} }
} }

View file

@ -1,6 +1,8 @@
package no.svorka.mcms.core package no.svorka.mcms.core
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.buildJsonObject
@ -17,13 +19,18 @@ import okhttp3.Response
import java.io.IOException import java.io.IOException
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import javax.net.ssl.SSLException import javax.net.ssl.SSLException
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException import kotlin.coroutines.resumeWithException
private fun Response.closeQuietly() { runCatching { close() } }
/** Suspend bridge for an OkHttp call, with coroutine cancellation. */ /** Suspend bridge for an OkHttp call, with coroutine cancellation. */
suspend fun Call.await(): Response = suspendCancellableCoroutine { cont -> suspend fun Call.await(): Response = suspendCancellableCoroutine { cont ->
enqueue(object : Callback { enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) { cont.resume(response) } 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 onFailure(call: Call, e: IOException) { override fun onFailure(call: Call, e: IOException) {
if (!cont.isCancelled) cont.resumeWithException(e) if (!cont.isCancelled) cont.resumeWithException(e)
} }
@ -32,9 +39,10 @@ suspend fun Call.await(): Response = suspendCancellableCoroutine { cont ->
} }
/** /**
* Transport for the MCMS REST API. Builds versioned requests, injects auth * Transport for the MCMS REST API. Builds requests, injects auth headers, unwraps
* headers, unwraps the `{status,data}` envelope, and maps status codes to * the `{status,data}` envelope, and maps status codes to [ApiError]. Returns raw
* [ApiError]. Returns raw [JsonElement] trees that the repositories wrap. * [JsonElement] trees that the repositories wrap. Endpoint versions are fixed per
* endpoint in [McmsEndpoint.path].
*/ */
class ApiClient( class ApiClient(
private val config: ApiConfiguration, private val config: ApiConfiguration,
@ -51,28 +59,30 @@ class ApiClient(
ServerTrust.apply(this, config.trustPolicy) ServerTrust.apply(this, config.trustPolicy)
}.build() }.build()
suspend fun getElement(endpoint: McmsEndpoint, query: ApiQuery = ApiQuery.EMPTY, version: String? = null): JsonElement = suspend fun getElement(endpoint: McmsEndpoint, query: ApiQuery = ApiQuery.EMPTY): JsonElement =
unwrapData(perform("GET", endpoint, query, null, version)) unwrapData(perform("GET", endpoint, query, null))
suspend fun getList(endpoint: McmsEndpoint, query: ApiQuery = ApiQuery.EMPTY, version: String? = null): List<JsonElement> = suspend fun getList(endpoint: McmsEndpoint, query: ApiQuery = ApiQuery.EMPTY): List<JsonElement> =
getElement(endpoint, query, version).array ?: emptyList() getElement(endpoint, query).array ?: emptyList()
/** Raw body, bypassing the envelope — for the bare-array CPE helper. */ /** Raw body, bypassing the envelope — for the bare-array CPE helper. */
suspend fun getRaw(endpoint: McmsEndpoint, version: String? = null): JsonElement { suspend fun getRaw(endpoint: McmsEndpoint): JsonElement {
val resp = perform("GET", endpoint, ApiQuery.EMPTY, null, version) val resp = perform("GET", endpoint, ApiQuery.EMPTY, null)
return if (resp.body.isBlank()) JsonNull else MCMSJson.parseToJsonElement(resp.body) if (resp.body.isBlank()) return JsonNull
return runCatching { MCMSJson.parseToJsonElement(resp.body) }
.getOrElse { throw ApiError.Decoding(it.message ?: "invalid JSON in response") }
} }
suspend fun send(method: String, endpoint: McmsEndpoint, body: JsonElement, wrapInData: Boolean = true, version: String? = null): JsonElement { suspend fun send(method: String, endpoint: McmsEndpoint, body: JsonElement, wrapInData: Boolean = true): JsonElement {
val payload = if (wrapInData) buildJsonObject { put("data", body) } else body val payload = if (wrapInData) buildJsonObject { put("data", body) } else body
return unwrapData(perform(method, endpoint, ApiQuery.EMPTY, payload.toString(), version)) return unwrapData(perform(method, endpoint, ApiQuery.EMPTY, payload.toString()))
} }
suspend fun action(method: String, endpoint: McmsEndpoint, version: String? = null): JsonElement = suspend fun action(method: String, endpoint: McmsEndpoint): JsonElement =
unwrapData(perform(method, endpoint, ApiQuery.EMPTY, null, version)) unwrapData(perform(method, endpoint, ApiQuery.EMPTY, null))
suspend fun delete(endpoint: McmsEndpoint, version: String? = null) { suspend fun delete(endpoint: McmsEndpoint) {
perform("DELETE", endpoint, ApiQuery.EMPTY, null, version) perform("DELETE", endpoint, ApiQuery.EMPTY, null)
} }
/** Paginates by the `next` cursor; page size 100 (field guide §3.4). */ /** Paginates by the `next` cursor; page size 100 (field guide §3.4). */
@ -80,33 +90,37 @@ class ApiClient(
endpoint: McmsEndpoint, endpoint: McmsEndpoint,
baseQuery: ApiQuery = ApiQuery.EMPTY, baseQuery: ApiQuery = ApiQuery.EMPTY,
pageLimit: Int = 100, pageLimit: Int = 100,
version: String? = null,
idOf: (JsonElement) -> String?, idOf: (JsonElement) -> String?,
): List<JsonElement> { ): List<JsonElement> {
val all = mutableListOf<JsonElement>() val all = mutableListOf<JsonElement>()
var cursor: String? = null var cursor: String? = null
do { do {
val q = baseQuery.copy(limit = pageLimit, next = cursor) val q = baseQuery.copy(limit = pageLimit, next = cursor)
val page = getList(endpoint, q, version) val page = getList(endpoint, q)
all += page all += page
cursor = if (page.size == pageLimit) page.lastOrNull()?.let(idOf) else null 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
} while (cursor != null) } while (cursor != null)
return all return all
} }
private data class Resp(val code: Int, val body: String) private data class Resp(val code: Int, val body: String)
private fun buildUrl(endpoint: McmsEndpoint, query: ApiQuery, version: String?): HttpUrl { private fun buildUrl(endpoint: McmsEndpoint, query: ApiQuery): HttpUrl {
val base = config.baseUrl.toHttpUrlOrNull() ?: throw ApiError.InvalidUrl val base = config.baseUrl.toHttpUrlOrNull() ?: throw ApiError.InvalidUrl
val builder = base.newBuilder() val builder = base.newBuilder()
// Raw ids; addPathSegments encodes once and keeps ':' in MAC ids raw. // Raw ids; addPathSegments encodes once and keeps ':' in MAC ids raw.
builder.addPathSegments(endpoint.path(version ?: config.apiVersion).removePrefix("/")) builder.addPathSegments(endpoint.path().removePrefix("/"))
for ((name, value) in query.items()) builder.addQueryParameter(name, value) for ((name, value) in query.items()) builder.addQueryParameter(name, value)
return builder.build() return builder.build()
} }
private suspend fun perform(method: String, endpoint: McmsEndpoint, query: ApiQuery, bodyJson: String?, version: String?): Resp { private suspend fun perform(method: String, endpoint: McmsEndpoint, query: ApiQuery, bodyJson: String?): Resp {
val url = buildUrl(endpoint, query, version) val url = buildUrl(endpoint, query)
val reqBody = when { val reqBody = when {
bodyJson != null -> bodyJson.toRequestBody(jsonMedia) bodyJson != null -> bodyJson.toRequestBody(jsonMedia)
method == "POST" || method == "PUT" || method == "PATCH" -> ByteArray(0).toRequestBody() method == "POST" || method == "PUT" || method == "PATCH" -> ByteArray(0).toRequestBody()
@ -114,21 +128,26 @@ class ApiClient(
} }
val builder = Request.Builder().url(url) val builder = Request.Builder().url(url)
.header("Accept", "application/json") .header("Accept", "application/json")
.header("User-Agent", USER_AGENT) // §3.1: an empty UA is rejected
.header("Referer", config.refererValue) .header("Referer", config.refererValue)
.method(method, reqBody) .method(method, reqBody)
if (bodyJson != null) builder.header("Content-Type", "application/json") if (bodyJson != null) builder.header("Content-Type", "application/json")
if (method != "GET") session.csrfToken()?.let { builder.header("X-CSRFToken", it) } if (method != "GET") session.csrfToken()?.let { builder.header("X-CSRFToken", it) }
val request = builder.build()
val response = try { // The blocking socket read (and connect) runs off the main thread; the
client.newCall(builder.build()).await() // status mapping + onAuthExpired callback resume on the caller (Main) so the
} catch (e: SSLException) { // Compose-state write in onAuthExpired stays on the main thread.
throw ApiError.ServerTrust val (code, body) = withContext(Dispatchers.IO) {
} catch (e: IOException) { val response = try {
throw ApiError.Transport(e.message ?: "request failed") 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 code = response.code
val body = response.body?.string() ?: ""
response.close()
if (code !in 200..299) { if (code !in 200..299) {
val details = runCatching { MCMSJson.parseToJsonElement(body)["details"] }.getOrNull() val details = runCatching { MCMSJson.parseToJsonElement(body)["details"] }.getOrNull()
@ -141,11 +160,18 @@ class ApiClient(
private fun unwrapData(resp: Resp): JsonElement { private fun unwrapData(resp: Resp): JsonElement {
if (resp.body.isBlank()) return JsonNull if (resp.body.isBlank()) return JsonNull
val root = MCMSJson.parseToJsonElement(resp.body) val root = runCatching { MCMSJson.parseToJsonElement(resp.body) }
.getOrElse { throw ApiError.Decoding(it.message ?: "invalid JSON in response") }
val status = root["status"].string val status = root["status"].string
if (status != null && !status.equals("success", ignoreCase = true)) { // 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)) {
throw ApiError.ApiFailure(root["details"]) throw ApiError.ApiFailure(root["details"])
} }
return root["data"] ?: JsonNull return root["data"] ?: JsonNull
} }
private companion object {
const val USER_AGENT = "PONGo-Android/1.0"
}
} }

View file

@ -1,33 +1,34 @@
package no.svorka.mcms.core package no.svorka.mcms.core
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import java.net.URI import java.net.URI
/** How to evaluate the server's TLS certificate. */ /** How to evaluate the server's TLS certificate. */
@Serializable @Serializable
sealed class ServerTrustPolicy { sealed class ServerTrustPolicy {
/** Default platform trust evaluation. */ /** Default platform trust evaluation (system CA store). The only default. */
@Serializable @Serializable
data object System : ServerTrustPolicy() data object System : ServerTrustPolicy()
/** Accept any certificate presented by this exact host (self-signed lab boxes). */ /**
@Serializable * Trust only if the leaf public-key SHA-256 (base64) matches one of these.
data class AllowSelfSignedForHost(val host: String) : ServerTrustPolicy() * 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
/** Trust only if the leaf public-key SHA-256 (base64) matches one of these. */ * yet set it programmatically if a deployment needs it.
*/
@Serializable @Serializable
data class PinPublicKeySha256(val pins: List<String>, val host: String) : ServerTrustPolicy() data class PinPublicKeySha256(val pins: List<String>, val host: String) : ServerTrustPolicy()
} }
/** /**
* Connection configuration for the MCMS REST API. URL-driven so the same build * Connection configuration for the MCMS REST API. URL-driven so the same build
* works direct or via reverse proxy. `baseUrl` includes the `/api` prefix, e.g. * works direct or via reverse proxy. `baseUrl` includes the `/api` prefix and is
* `https://mcms.lab.svorka.net/api`. * always HTTPS, e.g. `https://mcms.lab.svorka.net/api`.
*/ */
@Serializable @Serializable
data class ApiConfiguration( data class ApiConfiguration(
val baseUrl: String, val baseUrl: String,
val apiVersion: String = "v1",
val databaseId: String? = null, val databaseId: String? = null,
val refererOverride: String? = null, val refererOverride: String? = null,
val sessionCookieName: String = "sessionid", val sessionCookieName: String = "sessionid",
@ -36,24 +37,36 @@ data class ApiConfiguration(
val timeoutSeconds: Long = 30, val timeoutSeconds: Long = 30,
) { ) {
val refererValue: String get() = refererOverride ?: baseUrl val refererValue: String get() = refererOverride ?: baseUrl
val host: String? get() = runCatching { URI(baseUrl).host }.getOrNull()
/**
* 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
companion object { companion object {
/** /**
* Normalises a user-entered URL: requires scheme + host, trims a trailing * Normalises a user-entered URL and **enforces HTTPS**: defaults a missing
* slash, and defaults the path to `/api` when none is given. Returns null * scheme to `https`, rejects anything that isn't `https`, rejects embedded
* if it isn't a usable URL. * 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.
*/ */
fun normalizedBaseUrl(raw: String): String? { fun normalizedBaseUrl(raw: String): String? {
val trimmed = raw.trim() val trimmed = raw.trim()
val uri = runCatching { URI(trimmed) }.getOrNull() ?: return null if (trimmed.isEmpty()) return null
val scheme = uri.scheme ?: 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 host = uri.host?.takeIf { it.isNotEmpty() } ?: return null val host = uri.host?.takeIf { it.isNotEmpty() } ?: return null
var path = uri.path ?: "" var path = uri.path ?: ""
while (path.endsWith("/")) path = path.dropLast(1) while (path.endsWith("/")) path = path.dropLast(1)
if (path.isEmpty()) path = "/api" if (path.isEmpty()) path = "/api"
val port = if (uri.port >= 0) ":${uri.port}" else "" val port = if (uri.port >= 0) ":${uri.port}" else ""
return "$scheme://$host$port$path" return "https://$host$port$path"
} }
} }
} }

View file

@ -17,12 +17,20 @@ class FirmwareFile(val raw: JsonElement) {
val sizeBytes: Double? get() = raw["length"].double val sizeBytes: Double? get() = raw["length"].double
val uploadDate: String? get() = raw["uploadDate"].string val uploadDate: String? get() = raw["uploadDate"].string
/** Manufacturer must match the ONU vendor and the equipment id must be an /**
* exact (not substring) member of Compatible Model. */ * 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.
*/
fun isCompatible(vendor: String?, equipmentId: String?): Boolean { fun isCompatible(vendor: String?, equipmentId: String?): Boolean {
val mfg = manufacturer if (vendor == null || equipmentId == null) return false
if (vendor != null && !mfg.isNullOrEmpty() && !mfg.equals(vendor, ignoreCase = true)) return false val mfg = manufacturer ?: return false
if (equipmentId == null || models.isEmpty()) return true if (!mfg.equals(vendor, ignoreCase = true)) return false
if (models.isEmpty()) return false
return models.any { it.equals(equipmentId, ignoreCase = true) } return models.any { it.equals(equipmentId, ignoreCase = true) }
} }
} }

View file

@ -5,6 +5,10 @@ package no.svorka.mcms.core
* URL builder in [ApiClient] percent-encodes the whole path exactly once. * URL builder in [ApiClient] percent-encodes the whole path exactly once.
* Pre-encoding double-encoded MAC colons (`:` `%3A` `%253A`), which MCMS * Pre-encoding double-encoded MAC colons (`:` `%3A` `%253A`), which MCMS
* rejected as an invalid id. * 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 { sealed class McmsEndpoint {
data object Authenticate : McmsEndpoint() data object Authenticate : McmsEndpoint()
@ -37,38 +41,35 @@ sealed class McmsEndpoint {
data object OnuFirmwareFiles : McmsEndpoint() data object OnuFirmwareFiles : McmsEndpoint()
data class OnuCpe(val id: String) : McmsEndpoint() // versionless web helper data class OnuCpe(val id: String) : McmsEndpoint() // versionless web helper
fun path(version: String): String { fun path(): String = when (this) {
val v = "/$version" Authenticate -> "/v1/users/authenticate/" // §4
return when (this) { Logout -> "/v1/users/logout/" // §4
Authenticate -> "$v/users/authenticate/" DatabaseSelection -> "/v1/databases/selection/" // §4
Logout -> "$v/users/logout/" Version -> "/v1/ponmgr/version/" // inferred (probe)
DatabaseSelection -> "$v/databases/selection/"
Version -> "$v/ponmgr/version/"
ControllerConfigs -> "$v/controllers/configs/" ControllerConfigs -> "/v1/controllers/configs/" // inferred (non-fatal, runCatching)
is ControllerConfig -> "$v/controllers/configs/$id/" is ControllerConfig -> "/v1/controllers/configs/$id/"
is ControllerStats -> "$v/controllers/stats/$id/" is ControllerStats -> "/v1/controllers/stats/$id/"
is ControllerLogs -> "$v/controllers/logs/$id/" is ControllerLogs -> "/v1/controllers/logs/$id/"
OltConfigs -> "$v/olts/configs/" OltConfigs -> "/v3/olts/configs/" // inferred: configs-list family is v3 (cf. ONU)
is OltConfig -> "$v/olts/configs/$id/" is OltConfig -> "/v1/olts/configs/$id/" // inferred: single config is v1 (cf. ONU)
OltStates -> "$v/olts/states/" OltStates -> "/v3/olts/states/" // §4
is OltState -> "$v/olts/states/$id/" is OltState -> "/v3/olts/states/$id/" // inferred: states family is v3
is OltStats -> "$v/olts/stats/$id/" is OltStats -> "/v1/olts/stats/$id/" // §4
is OltLogs -> "$v/olts/logs/$id/" is OltLogs -> "/v1/olts/logs/$id/" // §4
is OltReset -> "$v/olts/$id/reset/" is OltReset -> "/v1/olts/$id/reset/" // inferred (prior default)
OnuConfigs -> "$v/onus/configs/" OnuConfigs -> "/v3/onus/configs/" // §4
is OnuConfig -> "$v/onus/configs/$id/" is OnuConfig -> "/v1/onus/configs/$id/" // §4
OnuStates -> "$v/onus/states/" OnuStates -> "/v3/onus/states/" // §4
is OnuState -> "$v/onus/states/$id/" is OnuState -> "/v3/onus/states/$id/" // §4
is OnuStats -> "$v/onus/stats/$id/" is OnuStats -> "/v1/onus/stats/$id/" // §4
is OnuLogs -> "$v/onus/logs/$id/" is OnuLogs -> "/v1/onus/logs/$id/" // §4
OnuAlarmHistories -> "$v/onus/alarm-histories/" OnuAlarmHistories -> "/v1/onus/alarm-histories/" // inferred (non-fatal, runCatching)
OnuCpeStates -> "$v/onus/cpe-states/" OnuCpeStates -> "/v1/onus/cpe-states/" // §4
is OnuReset -> "$v/onus/$id/reset/" is OnuReset -> "/v1/onus/$id/reset/" // inferred (prior default)
OnuFirmwareFiles -> "$v/files/onu-firmware/" OnuFirmwareFiles -> "/v1/files/onu-firmware/" // §4
is OnuCpe -> "/cpe/onu/$id/" is OnuCpe -> "/cpe/onu/$id/" // versionless web helper
}
} }
} }

View file

@ -4,18 +4,18 @@ import okhttp3.Cookie
import okhttp3.CookieJar import okhttp3.CookieJar
import okhttp3.HttpUrl import okhttp3.HttpUrl
import okhttp3.OkHttpClient 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 * 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. * 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 { class SessionCookieJar : CookieJar {
private val store = mutableMapOf<String, MutableList<Cookie>>() private val store = mutableMapOf<String, MutableList<Cookie>>()
@Synchronized
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) { override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
val list = store.getOrPut(url.host) { mutableListOf() } val list = store.getOrPut(url.host) { mutableListOf() }
cookies.forEach { c -> cookies.forEach { c ->
@ -24,15 +24,19 @@ class SessionCookieJar : CookieJar {
} }
} }
@Synchronized
override fun loadForRequest(url: HttpUrl): List<Cookie> = override fun loadForRequest(url: HttpUrl): List<Cookie> =
store[url.host]?.filter { it.matches(url) } ?: emptyList() store[url.host]?.filter { it.matches(url) } ?: emptyList()
@Synchronized
fun valueMatching(host: String, base: String): String? = fun valueMatching(host: String, base: String): String? =
store[host]?.firstOrNull { matches(it.name, base) }?.value store[host]?.firstOrNull { matches(it.name, base) }?.value
@Synchronized
fun hasCookie(host: String, base: String): Boolean = fun hasCookie(host: String, base: String): Boolean =
store[host]?.any { matches(it.name, base) } == true store[host]?.any { matches(it.name, base) } == true
@Synchronized
fun clear(host: String) { store.remove(host) } fun clear(host: String) { store.remove(host) }
// Modern MCMS builds emit `__Host-`/`__Secure-` prefixed cookies; accept either. // Modern MCMS builds emit `__Host-`/`__Secure-` prefixed cookies; accept either.
@ -64,21 +68,9 @@ class SessionStore(private val config: ApiConfiguration) {
object ServerTrust { object ServerTrust {
fun apply(builder: OkHttpClient.Builder, policy: ServerTrustPolicy) { fun apply(builder: OkHttpClient.Builder, policy: ServerTrustPolicy) {
when (policy) { when (policy) {
is ServerTrustPolicy.System -> Unit // default platform trust // Default platform trust (system CA store). Untrusted/self-signed
is ServerTrustPolicy.AllowSelfSignedForHost -> { // certificates are intentionally NOT accepted — pin instead.
// Accept ANY certificate, but the hostname verifier restricts it to is ServerTrustPolicy.System -> Unit
// the one allowed host (matches the iOS self-signed escape hatch).
val trustAll = object : X509TrustManager {
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {}
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) {}
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
}
val ctx = SSLContext.getInstance("TLS").apply {
init(null, arrayOf<javax.net.ssl.TrustManager>(trustAll), SecureRandom())
}
builder.sslSocketFactory(ctx.socketFactory, trustAll)
builder.hostnameVerifier { hostname, _ -> hostname.equals(policy.host, ignoreCase = true) }
}
is ServerTrustPolicy.PinPublicKeySha256 -> { is ServerTrustPolicy.PinPublicKeySha256 -> {
val pinner = okhttp3.CertificatePinner.Builder().apply { val pinner = okhttp3.CertificatePinner.Builder().apply {
policy.pins.forEach { add(policy.host, "sha256/$it") } policy.pins.forEach { add(policy.host, "sha256/$it") }

View file

@ -50,20 +50,19 @@ class AppState(context: Context) {
} }
val savedEmail: String? get() = keychain.get("email") val savedEmail: String? get() = keychain.get("email")
val savedPassword: String? get() = keychain.get("password")
suspend fun signIn(email: String, password: String, remember: Boolean) { suspend fun signIn(email: String, password: String, remember: Boolean) {
val conn = connection ?: throw ApiError.Transport("No connection configured") val conn = connection ?: throw ApiError.Transport("No connection configured")
conn.auth.login(email, password) conn.auth.login(email, password)
if (remember) { // Remember-me persists the email only; the password is never stored at rest.
keychain.set("email", email) if (remember) keychain.set("email", email) else keychain.delete("email")
keychain.set("password", password) keychain.delete("password") // clear any password persisted by an older build
}
isAuthenticated = true isAuthenticated = true
} }
suspend fun signOut() { suspend fun signOut() {
connection?.auth?.logout() runCatching { connection?.auth?.logout() }
keychain.delete("password") // defence-in-depth: never leave a password behind
isAuthenticated = false isAuthenticated = false
} }

View file

@ -16,13 +16,14 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import no.svorka.mcms.core.ApiConfiguration import no.svorka.mcms.core.ApiConfiguration
import no.svorka.mcms.core.ServerTrustPolicy
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun LoginScreen(app: AppState, onOpenSettings: () -> Unit) { fun LoginScreen(app: AppState, onOpenSettings: () -> Unit) {
var email by rememberSaveable { mutableStateOf(app.savedEmail ?: "") } var email by rememberSaveable { mutableStateOf(app.savedEmail ?: "") }
var password by rememberSaveable { mutableStateOf(app.savedPassword ?: "") } // 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 remember by rememberSaveable { mutableStateOf(true) } var remember by rememberSaveable { mutableStateOf(true) }
var phase by remember { mutableStateOf<LoadPhase>(LoadPhase.Idle) } var phase by remember { mutableStateOf<LoadPhase>(LoadPhase.Idle) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@ -79,11 +80,7 @@ fun LoginScreen(app: AppState, onOpenSettings: () -> Unit) {
@Composable @Composable
fun SettingsScreen(app: AppState, initialSetup: Boolean, onClose: () -> Unit) { fun SettingsScreen(app: AppState, initialSetup: Boolean, onClose: () -> Unit) {
var url by rememberSaveable { mutableStateOf(app.configuration?.baseUrl ?: "https://") } 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 dbId by rememberSaveable { mutableStateOf(app.configuration?.databaseId ?: "") }
var allowSelfSigned by rememberSaveable {
mutableStateOf(app.configuration?.trustPolicy is ServerTrustPolicy.AllowSelfSignedForHost)
}
var testPhase by remember { mutableStateOf<LoadPhase>(LoadPhase.Idle) } var testPhase by remember { mutableStateOf<LoadPhase>(LoadPhase.Idle) }
var testResult by remember { mutableStateOf<String?>(null) } var testResult by remember { mutableStateOf<String?>(null) }
var saveError by remember { mutableStateOf<String?>(null) } var saveError by remember { mutableStateOf<String?>(null) }
@ -91,15 +88,9 @@ fun SettingsScreen(app: AppState, initialSetup: Boolean, onClose: () -> Unit) {
fun makeConfig(): ApiConfiguration? { fun makeConfig(): ApiConfiguration? {
val base = ApiConfiguration.normalizedBaseUrl(url) ?: return null val base = ApiConfiguration.normalizedBaseUrl(url) ?: return null
val host = ApiConfiguration(baseUrl = base).host return ApiConfiguration(baseUrl = base, databaseId = dbId.ifBlank { null })
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 && apiVersion.isNotBlank() val canSave = ApiConfiguration.normalizedBaseUrl(url) != null
Scaffold(topBar = { Scaffold(topBar = {
TopAppBar( TopAppBar(
@ -110,7 +101,7 @@ fun SettingsScreen(app: AppState, initialSetup: Boolean, onClose: () -> Unit) {
enabled = canSave, enabled = canSave,
onClick = { onClick = {
val config = makeConfig() val config = makeConfig()
if (config == null) { saveError = "Enter a valid URL including scheme (https://)." } if (config == null) { saveError = "Enter a valid https:// URL." }
else { saveError = null; app.configure(config); if (!initialSetup) onClose() } else { saveError = null; app.configure(config); if (!initialSetup) onClose() }
}, },
) { Text("Save") } ) { Text("Save") }
@ -122,24 +113,15 @@ fun SettingsScreen(app: AppState, initialSetup: Boolean, onClose: () -> Unit) {
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
OutlinedTextField( OutlinedTextField(
value = url, onValueChange = { url = it }, label = { Text("Server URL") }, singleLine = true, value = url, onValueChange = { url = it }, label = { Text("Server URL (HTTPS)") }, singleLine = true,
supportingText = { Text("Host or origin, e.g. https://mcms.lab.svorka.net — “/api” is appended automatically.") }, supportingText = { Text("Host or origin, e.g. mcms.lab.svorka.net — HTTPS is required and “/api” is appended automatically.") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
OutlinedTextField(
value = apiVersion, onValueChange = { apiVersion = it }, label = { Text("API version") }, singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField( OutlinedTextField(
value = dbId, onValueChange = { dbId = it }, label = { Text("Database ID (optional)") }, singleLine = true, value = dbId, onValueChange = { dbId = it }, label = { Text("Database ID (optional)") }, singleLine = true,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(checked = allowSelfSigned, onCheckedChange = { allowSelfSigned = it })
Spacer(Modifier.width(8.dp))
Text("Allow self-signed certificate")
}
Button( Button(
enabled = canSave && testPhase != LoadPhase.Loading, enabled = canSave && testPhase != LoadPhase.Loading,

View file

@ -15,6 +15,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import no.svorka.mcms.core.* import no.svorka.mcms.core.*
@ -31,30 +32,46 @@ class DashboardModel(private val connection: McmsConnection) {
var lasersOff by mutableStateOf<List<OltStateDoc>>(emptyList()) var lasersOff by mutableStateOf<List<OltStateDoc>>(emptyList())
var opticalAvailable by mutableStateOf(false) 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) { suspend fun load(extraStats: Boolean) {
val gen = ++loadGen
phase = LoadPhase.Loading phase = LoadPhase.Loading
try { try {
// Fetch into locals, then commit atomically once we confirm we're still current.
val olts = connection.olt.allStates() val olts = connection.olt.allStates()
oltCount = olts.size
val reg = OltRepository.registrationMap(olts) val reg = OltRepository.registrationMap(olts)
onuTotal = reg.size val counts = reg.values.groupingBy { it }.eachCount().toList().sortedByDescending { it.second }
onuCounts = reg.values.groupingBy { it }.eachCount().toList().sortedByDescending { it.second } val down = olts.mapNotNull { it.txRateBps }.sum()
totalDownBps = olts.mapNotNull { it.txRateBps }.sum() val up = olts.mapNotNull { it.rxRateBps }.sum()
totalUpBps = olts.mapNotNull { it.rxRateBps }.sum() val lasers = olts.filter { it.isLaserOff }
lasersOff = olts.filter { it.isLaserOff } val ctrl = runCatching { connection.controller.allConfigs().size }.getOrDefault(0)
controllerCount = runCatching { connection.controller.allConfigs().size }.getOrDefault(0)
val alarms = runCatching { connection.onu.alarmHistories() }.getOrDefault(emptyList()) val alarms = runCatching { connection.onu.alarmHistories() }.getOrDefault(emptyList())
severityCounts = alarms.groupingBy { it.severity }.eachCount().toList().sortedBy { it.first.rank } val sev = alarms.groupingBy { it.severity }.eachCount().toList().sortedBy { it.first.rank }
if (extraStats) { val (optAvail, abnormal) = if (extraStats) {
val onus = connection.onu.allStates() val onus = connection.onu.allStates()
opticalAvailable = onus.any { it.rxOpticalDBm != null } onus.any { it.rxOpticalDBm != null } to
abnormalRx = onus.filter { val rx = it.rxOpticalDBm; rx != null && (rx < -28 || rx > -10) } onus.filter { val rx = it.rxOpticalDBm; rx != null && (rx < -28 || rx > -10) }
} else { } else {
opticalAvailable = false; abnormalRx = emptyList() false to emptyList<OnuStateDoc>()
} }
if (gen != loadGen) return // a newer load superseded us — drop stale results
oltCount = olts.size
onuTotal = reg.size
onuCounts = counts
totalDownBps = down
totalUpBps = up
lasersOff = lasers
controllerCount = ctrl
severityCounts = sev
opticalAvailable = optAvail
abnormalRx = abnormal
phase = LoadPhase.Loaded phase = LoadPhase.Loaded
} catch (e: CancellationException) {
throw e // cancellation is not a failure
} catch (e: Throwable) { } catch (e: Throwable) {
phase = LoadPhase.Failed(e.uiMessage()) if (gen == loadGen) phase = LoadPhase.Failed(e.uiMessage())
} }
} }
} }
@ -102,7 +119,8 @@ fun DashboardScreen(
Box(Modifier.padding(padding).fillMaxSize()) { Box(Modifier.padding(padding).fillMaxSize()) {
when (val p = model.phase) { 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) is LoadPhase.Idle, is LoadPhase.Loading -> if (model.onuTotal == 0 && model.oltCount == 0) LoadingView("Loading network…") else content(model, openAbnormalRx, openLaserOff)
is LoadPhase.Failed -> ErrorState(p.message) // 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.Loaded -> content(model, openAbnormalRx, openLaserOff) is LoadPhase.Loaded -> content(model, openAbnormalRx, openLaserOff)
} }
} }

View file

@ -12,6 +12,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import no.svorka.mcms.core.* import no.svorka.mcms.core.*
@ -27,16 +28,23 @@ class OltListModel(private val connection: McmsConnection) {
return olts.filter { it.id.contains(q, true) || (it.resolvedName?.contains(q, true) == true) } return olts.filter { it.id.contains(q, true) || (it.resolvedName?.contains(q, true) == true) }
} }
private var loadGen = 0
suspend fun load() { suspend fun load() {
val gen = ++loadGen
phase = LoadPhase.Loading phase = LoadPhase.Loading
try { try {
val fetched = connection.olt.allStates() val fetched = connection.olt.allStates()
val names = runCatching { connection.olt.nameMap() }.getOrDefault(emptyMap()) val names = runCatching { connection.olt.nameMap() }.getOrDefault(emptyMap())
fetched.forEach { it.resolvedName = names[it.id] } fetched.forEach { it.resolvedName = names[it.id] }
olts = fetched.sortedBy { it.displayName.lowercase() } val sorted = fetched.sortedBy { it.displayName.lowercase() }
if (gen != loadGen) return
olts = sorted
phase = LoadPhase.Loaded phase = LoadPhase.Loaded
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) { } catch (e: Throwable) {
phase = LoadPhase.Failed(e.uiMessage()) if (gen == loadGen) phase = LoadPhase.Failed(e.uiMessage())
} }
} }
} }
@ -45,6 +53,7 @@ class OltListModel(private val connection: McmsConnection) {
@Composable @Composable
fun OltListScreen(connection: McmsConnection, openOlt: (String, String) -> Unit) { fun OltListScreen(connection: McmsConnection, openOlt: (String, String) -> Unit) {
val model = remember(connection) { OltListModel(connection) } val model = remember(connection) { OltListModel(connection) }
val scope = rememberCoroutineScope()
LaunchedEffect(connection) { if (model.phase is LoadPhase.Idle) model.load() } LaunchedEffect(connection) { if (model.phase is LoadPhase.Idle) model.load() }
Scaffold(topBar = { TopAppBar(title = { Text("OLTs") }) }) { padding -> Scaffold(topBar = { TopAppBar(title = { Text("OLTs") }) }) { padding ->
@ -56,7 +65,7 @@ fun OltListScreen(connection: McmsConnection, openOlt: (String, String) -> Unit)
) )
when (val p = model.phase) { 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.Idle, is LoadPhase.Loading -> if (model.olts.isEmpty()) LoadingView("Loading OLTs…") else OltList(model.filtered, openOlt)
is LoadPhase.Failed -> ErrorState(p.message) is LoadPhase.Failed -> ErrorState(p.message) { scope.launch { model.load() } }
is LoadPhase.Loaded -> OltList(model.filtered, openOlt) is LoadPhase.Loaded -> OltList(model.filtered, openOlt)
} }
} }
@ -92,16 +101,30 @@ class OltDetailModel(private val connection: McmsConnection, val oltId: String)
var actionMessage by mutableStateOf<String?>(null) var actionMessage by mutableStateOf<String?>(null)
var busy by mutableStateOf(false) var busy by mutableStateOf(false)
private var loadGen = 0
suspend fun load() { suspend fun load() {
val gen = ++loadGen
phase = LoadPhase.Loading phase = LoadPhase.Loading
try { olt = connection.olt.state(oltId); phase = LoadPhase.Loaded } try {
catch (e: Throwable) { phase = LoadPhase.Failed(e.uiMessage()) } 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())
}
} }
suspend fun reset() { suspend fun reset() {
if (busy) return
busy = true; actionMessage = null busy = true; actionMessage = null
try { connection.olt.reset(oltId); actionMessage = "Reset requested." } catch (e: Throwable) { actionMessage = e.uiMessage() } try { connection.olt.reset(oltId); actionMessage = "Reset requested." }
busy = false catch (e: CancellationException) { throw e }
catch (e: Throwable) { actionMessage = e.uiMessage() }
finally { busy = false }
} }
} }

View file

@ -16,6 +16,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonElement
@ -39,17 +40,24 @@ class OnuListModel(private val connection: McmsConnection) {
} }
} }
private var loadGen = 0
suspend fun load() { suspend fun load() {
val gen = ++loadGen
phase = LoadPhase.Loading phase = LoadPhase.Loading
try { try {
val fetched = connection.onu.allStates() val fetched = connection.onu.allStates()
val reg = runCatching { connection.olt.registrationMap() }.getOrDefault(emptyMap()) val reg = runCatching { connection.olt.registrationMap() }.getOrDefault(emptyMap())
val names = runCatching { connection.onu.nameMap() }.getOrDefault(emptyMap()) val names = runCatching { connection.onu.nameMap() }.getOrDefault(emptyMap())
fetched.forEach { it.resolvedLifecycle = reg[it.id]; it.resolvedName = names[it.id] } fetched.forEach { it.resolvedLifecycle = reg[it.id]; it.resolvedName = names[it.id] }
onus = fetched.sortedBy { it.displayName.lowercase() } val sorted = fetched.sortedBy { it.displayName.lowercase() }
if (gen != loadGen) return
onus = sorted
phase = LoadPhase.Loaded phase = LoadPhase.Loaded
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) { } catch (e: Throwable) {
phase = LoadPhase.Failed(e.uiMessage()) if (gen == loadGen) phase = LoadPhase.Failed(e.uiMessage())
} }
} }
} }
@ -58,6 +66,7 @@ class OnuListModel(private val connection: McmsConnection) {
@Composable @Composable
fun OnuListScreen(connection: McmsConnection, openOnu: (String) -> Unit) { fun OnuListScreen(connection: McmsConnection, openOnu: (String) -> Unit) {
val model = remember(connection) { OnuListModel(connection) } val model = remember(connection) { OnuListModel(connection) }
val scope = rememberCoroutineScope()
LaunchedEffect(connection) { if (model.phase is LoadPhase.Idle) model.load() } LaunchedEffect(connection) { if (model.phase is LoadPhase.Idle) model.load() }
Scaffold(topBar = { TopAppBar(title = { Text("ONUs") }) }) { padding -> Scaffold(topBar = { TopAppBar(title = { Text("ONUs") }) }) { padding ->
@ -69,7 +78,7 @@ fun OnuListScreen(connection: McmsConnection, openOnu: (String) -> Unit) {
) )
when (val p = model.phase) { 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.Idle, is LoadPhase.Loading -> if (model.onus.isEmpty()) LoadingView("Loading ONUs…") else OnuList(model.filtered, openOnu)
is LoadPhase.Failed -> ErrorState(p.message) { /* retry */ } is LoadPhase.Failed -> ErrorState(p.message) { scope.launch { model.load() } }
is LoadPhase.Loaded -> OnuList(model.filtered, openOnu) is LoadPhase.Loaded -> OnuList(model.filtered, openOnu)
} }
} }
@ -116,40 +125,56 @@ class OnuDetailModel(private val connection: McmsConnection, val onuId: String)
var actionMessage by mutableStateOf<String?>(null) var actionMessage by mutableStateOf<String?>(null)
var busy by mutableStateOf(false) 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 loadTab() { phase = LoadPhase.Loading; reload() }
suspend fun refresh() { reload() } suspend fun refresh() { reload() }
private suspend fun reload() { private suspend fun reload() {
val gen = ++loadGen
try { try {
when (tab) { when (tab) {
0 -> liveState = connection.onu.state(onuId) 0 -> { val v = connection.onu.state(onuId); if (gen != loadGen) return; liveState = v }
1 -> cpes = connection.onu.cpes(onuId) 1 -> { val v = connection.onu.cpes(onuId); if (gen != loadGen) return; cpes = v }
2 -> logs = connection.onu.logs(onuId) 2 -> { val v = connection.onu.logs(onuId); if (gen != loadGen) return; logs = v }
3 -> config = connection.onu.config(onuId) 3 -> { val v = connection.onu.config(onuId); if (gen != loadGen) return; config = v }
} }
if (gen != loadGen) return
phase = LoadPhase.Loaded phase = LoadPhase.Loaded
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) { } catch (e: Throwable) {
phase = LoadPhase.Failed(e.uiMessage()) if (gen == loadGen) phase = LoadPhase.Failed(e.uiMessage())
} }
} }
suspend fun loadFirmware() { suspend fun loadFirmware() {
firmwareError = null firmwareError = null
try { firmwareFiles = connection.onu.firmwareFiles() } catch (e: Throwable) { firmwareError = e.uiMessage() } try { firmwareFiles = connection.onu.firmwareFiles() }
catch (e: CancellationException) { throw e }
catch (e: Throwable) { firmwareError = e.uiMessage() }
} }
suspend fun reset() { suspend fun reset() {
if (busy) return
busy = true; actionMessage = null busy = true; actionMessage = null
try { connection.onu.reset(onuId); actionMessage = "Reset requested." } catch (e: Throwable) { actionMessage = e.uiMessage() } try { connection.onu.reset(onuId); actionMessage = "Reset requested." }
busy = false catch (e: CancellationException) { throw e }
catch (e: Throwable) { actionMessage = e.uiMessage() }
finally { busy = false }
} }
suspend fun upgrade(file: FirmwareFile) { suspend fun upgrade(file: FirmwareFile) {
if (busy) return
busy = true; actionMessage = null busy = true; actionMessage = null
try { try {
val slot = connection.onu.upgradeFirmware(onuId, file) val slot = connection.onu.upgradeFirmware(onuId, file)
actionMessage = "Firmware ${file.version ?: "image"} staged to bank $slot. The ONU starts it after its next reboot." actionMessage = "Firmware ${file.version ?: "image"} staged to bank $slot. The ONU starts it after its next reboot."
} catch (e: Throwable) { actionMessage = e.uiMessage() } }
busy = false catch (e: CancellationException) { throw e }
catch (e: Throwable) { actionMessage = e.uiMessage() }
finally { busy = false }
} }
} }
@ -438,12 +463,32 @@ private fun FirmwareDialog(model: OnuDetailModel, onDismiss: () -> Unit) {
) )
pending?.let { file -> pending?.let { file ->
val isCompatible = file.isCompatible(state?.vendor, state?.equipmentId)
var ack by remember(file) { mutableStateOf(false) }
AlertDialog( AlertDialog(
onDismissRequest = { pending = null }, onDismissRequest = { pending = null },
title = { Text("Stage firmware?") }, title = { Text("Stage firmware?") },
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.") }, 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")
}
}
}
},
confirmButton = { confirmButton = {
TextButton(onClick = { pending = null; onDismiss(); scope.launch { model.upgrade(file) } }) { TextButton(
enabled = isCompatible || ack,
onClick = { pending = null; onDismiss(); scope.launch { model.upgrade(file) } },
) {
Text("Download ${file.version ?: "firmware"}", color = MaterialTheme.colorScheme.error) Text("Download ${file.version ?: "firmware"}", color = MaterialTheme.colorScheme.error)
} }
}, },

View file

@ -2,6 +2,7 @@ package no.svorka.mcms.ui
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import no.svorka.mcms.core.AlarmSeverity import no.svorka.mcms.core.AlarmSeverity
import no.svorka.mcms.core.ApiError
import no.svorka.mcms.core.OnuLifecycleState import no.svorka.mcms.core.OnuLifecycleState
/** Screen load state. */ /** Screen load state. */
@ -31,5 +32,9 @@ fun AlarmSeverity.color(): Color = when (this) {
AlarmSeverity.UNKNOWN -> Color(0xFF9E9E9E) AlarmSeverity.UNKNOWN -> Color(0xFF9E9E9E)
} }
/** Maps a thrown error to a user message. */ /**
fun Throwable.uiMessage(): String = message ?: "Something went wrong" * 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."

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Transport security policy. Cleartext is never permitted, and only the system CA
store is trusted — user-installed CAs are not, which closes the "malicious profile"
MITM vector. To reach a box with a private/self-signed cert, install its CA in the
device system store or pin it via ServerTrustPolicy.PinPublicKeySha256.
-->
<network-security-config>
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
</network-security-config>