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:
parent
ca3d7d05e0
commit
41098831ae
20 changed files with 505 additions and 191 deletions
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal 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
103
CHANGELOG.md
Normal 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.
|
||||||
24
HANDOFF.md
24
HANDOFF.md
|
|
@ -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 ~20–60 s. This is what proves the whole app
|
to `~/.gradle`; afterwards it's ~20–60 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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
33
app/proguard-rules.pro
vendored
33
app/proguard-rules.pro
vendored
|
|
@ -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.**
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
// 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 {
|
val response = try {
|
||||||
client.newCall(builder.build()).await()
|
client.newCall(request).await()
|
||||||
} catch (e: SSLException) {
|
} catch (e: SSLException) {
|
||||||
throw ApiError.ServerTrust
|
throw ApiError.ServerTrust
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
throw ApiError.Transport(e.message ?: "request failed")
|
throw ApiError.Transport(e.message ?: "request failed")
|
||||||
}
|
}
|
||||||
val code = response.code
|
response.use { it.code to (it.body?.string() ?: "") }
|
||||||
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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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") }
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
|
|
|
||||||
14
app/src/main/res/xml/network_security_config.xml
Normal file
14
app/src/main/res/xml/network_security_config.xml
Normal 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>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue