commit ca3d7d05e0ed8ba028f40b3024d6e8a133e09aa8 Author: Jon Vanvik Date: Sun May 31 14:09:00 2026 +0200 initial commit diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 0000000..2167010 --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,92 @@ +# Handoff — PON Go Android + +Context for the next session/developer. Pairs with [`README.md`](README.md) and +[`docs/MCMS_API.md`](docs/MCMS_API.md). + +## Architecture + +- **`core/`** — pure Kotlin, no Android UI deps. Mirrors the iOS networking core. + - JSON: MCMS docs are heterogeneous Mongo documents, so we keep raw + `kotlinx.serialization` `JsonElement` trees and read fields via the accessors + in `Json.kt` (`el["key"]`, `.string/.int/.double/.array/.obj`) — the analogue + of the iOS `JSONValue`. Models (`Models.kt`) are thin wrappers over a + `JsonElement` with computed properties. + - `ApiClient` (OkHttp): builds versioned requests, injects `Referer` + + `X-CSRFToken` (writes), unwraps the `{status,data}` envelope, maps status + codes to `ApiError`, paginates via the `next` cursor. `Call.await()` bridges + OkHttp to coroutines with cancellation. + - Repositories return model wrappers; `McmsConnection` wires it all; auth/cookies + in `Networking.kt` (`SessionCookieJar` + `SessionStore`), TLS policy in + `ServerTrust`, secrets in `KeychainStore` (EncryptedSharedPreferences). +- **`ui/`** — Jetpack Compose. Navigation is **state-based** in `MainActivity` + (a `List` back stack + `BackHandler`), not Navigation-Compose — so + detail screens and the dashboard drill-downs can take in-memory objects + directly. Each screen has a plain **state-holder class** (`…Model`) exposing + `mutableStateOf` fields + `suspend load()/refresh()` — the analogue of the iOS + `@Observable` view models. No androidx `ViewModel`/DI. + +## Build / verify loop (this matters) + +The dev machine had **no `kotlinc`/`gradle` on PATH**, but Android Studio provides +everything. Two loops: + +- **Full build (authoritative):** + ``` + export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" + ./gradlew :app:assembleDebug --no-daemon --console=plain + ``` + Needs `local.properties` (`sdk.dir=…`, git-ignored). First run downloads deps + to `~/.gradle`; afterwards it's ~20–60 s. This is what proves the whole app + (UI + resources + manifest + icons) compiles. +- **Quick core-only check (no Gradle), when iterating on `core/`:** the bundled + compiler at `…/Android Studio.app/Contents/plugins/Kotlin/kotlinc/bin/kotlinc` + with `-Xplugin=…/kotlinc/lib/kotlinx-serialization-compiler-plugin.jar` and a + classpath of Maven jars (kotlinx-serialization-json/core, okhttp, okio-jvm, + kotlinx-coroutines-core-jvm). Compiles every `core/*.kt` except + `KeychainStore.kt` (androidx). Faster than a Gradle round-trip. + +## API gotchas (all handled in code — don't regress) + +- **Single-encode URLs.** Ids stay RAW in `McmsEndpoint`; `ApiClient` encodes the + path once via `HttpUrl.addPathSegments`. Pre-encoding double-encoded MAC colons + (`:` → `%3A` → `%253A`) → MCMS "invalid id". +- **401 bounces to login; 403 does NOT** (permission/CSRF — `requiresReauthentication` + is 401-only). +- **Writes return `{"status":"success"}` with no `data`** — the unwrap tolerates it. +- **ONU registration lives on OLT-STATE buckets** (`OltRepository.registrationMap`), + not ONU-STATE. Friendly names are `ONU.Name` / `OLT.Name` (NOT `NETCONF.Name`). +- **CPE DHCP** comes from the web helper `GET /api/cpe/onu//` — a versionless, + bare `[code, cpe…]` array (use `ApiClient.getRaw`). +- **Firmware** = Procedure 7: GET ONU-CFG → write filename/version into the + **inactive** bank → full-doc PUT (`FirmwareUpgrade.apply`). Inactive-bank only, + so service stays up until the ONU reboots. Still worth testing on a spare ONU. +- Field paths were confirmed against live JSON; see `docs/MCMS_API.md` and the + model accessors. + +## Trimmed vs iOS (intentional, easy to restore) + +- ONU detail tabs are **Overview / CPE / Logs / Config** — no separate Alarms tab. + Current alarms live on ONU-STATE `"Alarm"` buckets if you want to add it. +- **Config** shows pretty-printed JSON text, not the collapsible tree the iOS app + has. +- Dashboard **ONU-state rows aren't tappable** (the Health rows are). The iOS app + drills ONU-state → filtered ONU list. +- Toggles use `rememberSaveable` (survive rotation, not process death). Persist in + prefs if you want parity with iOS `@AppStorage`. +- Self-signed/cleartext: the TLS trust policy is in `ServerTrust`. Plain-HTTP hosts + also need a `usesCleartextTraffic`/network-security-config exception (Android's + ATS analogue) — add when needed. + +## Repo split + +This folder is self-contained (has its own `docs/MCMS_API.md`). To make it a git +repo: `git init` here; the included `.gitignore` already excludes `build/`, +`.gradle/`, `local.properties`, `.idea/`. Commit the Gradle wrapper +(`gradlew`, `gradle/wrapper/*`). The full dev-guide PDFs (`323-1961-30x`) live in +the iOS repo if you ever need the deep source. + +## Possible next steps + +Restore the trimmed items above; add OLT firmware (Procedure 8 bulk task); +per-ONU live-alarms view; charts for PM history; pull-to-refresh on lists; +dark-theme polish; a proper app theme from the brand colours. diff --git a/README.md b/README.md new file mode 100644 index 0000000..836b0d7 --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +# PON Go — Android + +Native Android app for Ciena **MCMS 6.2 PON Manager** basics on the go — dashboard, +ONU and OLT browsing/diagnostics, firmware updates, resets. Kotlin + Jetpack +Compose + OkHttp. Sibling of the iOS app (`../mcms_ios`); the REST behaviour and +field paths are identical and documented in [`docs/MCMS_API.md`](docs/MCMS_API.md). + +**Status: complete and building.** `./gradlew :app:assembleDebug` → `BUILD +SUCCESSFUL`, runs in the emulator. Package `no.svorka.mcms`, app name **PON Go**. + +## Build & run + +1. Open the folder in **Android Studio** → let it sync → Run on a device/emulator + that can reach your MCMS host. +2. Or CLI: `./gradlew :app:assembleDebug` (needs `local.properties` with + `sdk.dir=…`, and a JDK 17+ — Android Studio's bundled JBR works). + +Verified toolchain on the dev machine: **AGP 8.11.0, Kotlin 2.0.21, Gradle 8.13, +compileSdk 36, minSdk 26, JBR 21**. + +## What it does (feature parity with iOS) + +- **Login / Settings** — URL normalisation (`/api` auto-appended), self-signed + TLS toggle, test-connection probe, remember-me (Android Keystore), sign out. +- **Dashboard** — ONU/OLT/controller counts; total OLT traffic; a **Health** + section (abnormal-Rx ONUs, laser-off OLTs) that drills into the affected + devices; ONU-state breakdown; **Detailed stats** (opt-in Rx scan) and + **Auto-refresh 30 s** toggles. +- **ONUs** — searchable list; detail with **Overview** (status, firmware + + update picker, optical/FEC, UNI ports, traffic), **CPE** (DHCPv4/v6 leases), + **Logs**, **Config** (raw JSON). Reset + firmware behind hardened, + acknowledge-to-enable, red confirmations. +- **OLTs** — list with up/down summary; detail with status/uptime, PON traffic, + environment, uplink (LLDP), registration buckets (→ ONU detail), reset. + +## Layout + +``` +app/src/main/java/no/svorka/mcms/ + MainActivity.kt root: state-based nav + bottom bar + core/ networking / models / repositories (≈ iOS core) + Json, ApiConfiguration, ApiError, ApiQuery, McmsEndpoint, Util, + Models, AuthModels, Firmware, Networking, ApiClient, Repositories, + McmsConnection, KeychainStore + ui/ Jetpack Compose + Theme, Types, Components, AppState, + AuthScreens (Login+Settings), DashboardScreen (+drill-downs), + OnuScreens (list+detail+firmware+reset), OltScreens (list+detail) +app/src/main/res/ adaptive launcher icon, strings (app_name), colors +docs/MCMS_API.md the MCMS REST field guide (single source of truth) +``` + +See [`HANDOFF.md`](HANDOFF.md) for architecture notes, the build/verify loop, the +API gotchas, and what's deliberately trimmed vs iOS. diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..9e20819 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,61 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") + id("org.jetbrains.kotlin.plugin.serialization") +} + +android { + namespace = "no.svorka.mcms" + compileSdk = 36 + + defaultConfig { + applicationId = "no.svorka.mcms" + minSdk = 26 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + } + + buildFeatures { + compose = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } +} + +dependencies { + val composeBom = platform("androidx.compose:compose-bom:2024.09.02") + implementation(composeBom) + + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + debugImplementation("androidx.compose.ui:ui-tooling") + + implementation("androidx.core:core-ktx:1.13.1") + implementation("androidx.activity:activity-compose:1.9.2") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.6") + implementation("androidx.navigation:navigation-compose:2.8.1") + + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") + + implementation("androidx.security:security-crypto:1.1.0-alpha06") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..744eec1 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,4 @@ +# Keep kotlinx.serialization metadata. +-keepattributes *Annotation*, InnerClasses +-dontnote kotlinx.serialization.** +-keepclassmembers class kotlinx.serialization.json.** { *; } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..66e4321 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/no/svorka/mcms/MainActivity.kt b/app/src/main/java/no/svorka/mcms/MainActivity.kt new file mode 100644 index 0000000..4b9cb5d --- /dev/null +++ b/app/src/main/java/no/svorka/mcms/MainActivity.kt @@ -0,0 +1,107 @@ +package no.svorka.mcms + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Dashboard +import androidx.compose.material.icons.filled.Hub +import androidx.compose.material.icons.filled.Router +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import no.svorka.mcms.core.McmsConnection +import no.svorka.mcms.core.OltStateDoc +import no.svorka.mcms.core.OnuStateDoc +import no.svorka.mcms.ui.* + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + val app = remember { AppState(applicationContext) } + PonGoTheme { RootApp(app) } + } + } +} + +private sealed interface Screen { + data object Main : Screen + data class OnuDetail(val id: String) : Screen + data class OltDetail(val id: String, val title: String) : Screen + data class AbnormalRx(val onus: List) : Screen + data class LaserOff(val olts: List) : Screen +} + +@Composable +private fun RootApp(app: AppState) { + when { + !app.isConfigured -> SettingsScreen(app, initialSetup = true, onClose = {}) + !app.isAuthenticated -> { + var showSettings by remember { mutableStateOf(false) } + if (showSettings) SettingsScreen(app, initialSetup = false, onClose = { showSettings = false }) + else LoginScreen(app, onOpenSettings = { showSettings = true }) + } + else -> { + val connection = app.connection + if (connection == null) { SettingsScreen(app, initialSetup = true, onClose = {}); return } + var stack by remember { mutableStateOf>(listOf(Screen.Main)) } + BackHandler(enabled = stack.size > 1) { stack = stack.dropLast(1) } + when (val top = stack.last()) { + Screen.Main -> MainScaffold( + app, connection, + openOnu = { stack = stack + Screen.OnuDetail(it) }, + openOlt = { id, t -> stack = stack + Screen.OltDetail(id, t) }, + openAbnormalRx = { stack = stack + Screen.AbnormalRx(it) }, + openLaserOff = { stack = stack + Screen.LaserOff(it) }, + ) + is Screen.OnuDetail -> OnuDetailScreen(connection, top.id, onBack = { stack = stack.dropLast(1) }) + is Screen.OltDetail -> OltDetailScreen(connection, top.id, top.title, onBack = { stack = stack.dropLast(1) }, openOnu = { stack = stack + Screen.OnuDetail(it) }) + is Screen.AbnormalRx -> AbnormalRxScreen(top.onus, onBack = { stack = stack.dropLast(1) }, openOnu = { stack = stack + Screen.OnuDetail(it) }) + is Screen.LaserOff -> LaserOffScreen(top.olts, onBack = { stack = stack.dropLast(1) }, openOlt = { id, t -> stack = stack + Screen.OltDetail(id, t) }) + } + } + } +} + +@Composable +private fun MainScaffold( + app: AppState, + connection: McmsConnection, + openOnu: (String) -> Unit, + openOlt: (String, String) -> Unit, + openAbnormalRx: (List) -> Unit, + openLaserOff: (List) -> Unit, +) { + var tab by rememberSaveable { mutableStateOf(0) } + Scaffold(bottomBar = { + NavigationBar { + NavigationBarItem(selected = tab == 0, onClick = { tab = 0 }, icon = { Icon(Icons.Filled.Dashboard, null) }, label = { Text("Dashboard") }) + NavigationBarItem(selected = tab == 1, onClick = { tab = 1 }, icon = { Icon(Icons.Filled.Hub, null) }, label = { Text("ONUs") }) + NavigationBarItem(selected = tab == 2, onClick = { tab = 2 }, icon = { Icon(Icons.Filled.Router, null) }, label = { Text("OLTs") }) + NavigationBarItem(selected = tab == 3, onClick = { tab = 3 }, icon = { Icon(Icons.Filled.Settings, null) }, label = { Text("Settings") }) + } + }) { padding -> + Box(Modifier.padding(padding)) { + when (tab) { + 0 -> DashboardScreen(connection, openAbnormalRx, openLaserOff) + 1 -> OnuListScreen(connection, openOnu) + 2 -> OltListScreen(connection, openOlt) + else -> SettingsScreen(app, initialSetup = false, onClose = { tab = 0 }) + } + } + } +} diff --git a/app/src/main/java/no/svorka/mcms/core/ApiClient.kt b/app/src/main/java/no/svorka/mcms/core/ApiClient.kt new file mode 100644 index 0000000..aa63470 --- /dev/null +++ b/app/src/main/java/no/svorka/mcms/core/ApiClient.kt @@ -0,0 +1,151 @@ +package no.svorka.mcms.core + +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import okhttp3.Call +import okhttp3.Callback +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import java.io.IOException +import java.util.concurrent.TimeUnit +import javax.net.ssl.SSLException +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +/** Suspend bridge for an OkHttp call, with coroutine cancellation. */ +suspend fun Call.await(): Response = suspendCancellableCoroutine { cont -> + enqueue(object : Callback { + override fun onResponse(call: Call, response: Response) { cont.resume(response) } + override fun onFailure(call: Call, e: IOException) { + if (!cont.isCancelled) cont.resumeWithException(e) + } + }) + cont.invokeOnCancellation { runCatching { cancel() } } +} + +/** + * Transport for the MCMS REST API. Builds versioned requests, injects auth + * headers, unwraps the `{status,data}` envelope, and maps status codes to + * [ApiError]. Returns raw [JsonElement] trees that the repositories wrap. + */ +class ApiClient( + private val config: ApiConfiguration, + private val session: SessionStore, +) { + /** Fired on 401 so the app can route back to login. */ + var onAuthExpired: (() -> Unit)? = null + + private val jsonMedia = "application/json".toMediaType() + + private val client: OkHttpClient = OkHttpClient.Builder().apply { + cookieJar(session.cookieJar) + callTimeout(config.timeoutSeconds, TimeUnit.SECONDS) + ServerTrust.apply(this, config.trustPolicy) + }.build() + + suspend fun getElement(endpoint: McmsEndpoint, query: ApiQuery = ApiQuery.EMPTY, version: String? = null): JsonElement = + unwrapData(perform("GET", endpoint, query, null, version)) + + suspend fun getList(endpoint: McmsEndpoint, query: ApiQuery = ApiQuery.EMPTY, version: String? = null): List = + getElement(endpoint, query, version).array ?: emptyList() + + /** Raw body, bypassing the envelope — for the bare-array CPE helper. */ + suspend fun getRaw(endpoint: McmsEndpoint, version: String? = null): JsonElement { + val resp = perform("GET", endpoint, ApiQuery.EMPTY, null, version) + return if (resp.body.isBlank()) JsonNull else MCMSJson.parseToJsonElement(resp.body) + } + + suspend fun send(method: String, endpoint: McmsEndpoint, body: JsonElement, wrapInData: Boolean = true, version: String? = null): JsonElement { + val payload = if (wrapInData) buildJsonObject { put("data", body) } else body + return unwrapData(perform(method, endpoint, ApiQuery.EMPTY, payload.toString(), version)) + } + + suspend fun action(method: String, endpoint: McmsEndpoint, version: String? = null): JsonElement = + unwrapData(perform(method, endpoint, ApiQuery.EMPTY, null, version)) + + suspend fun delete(endpoint: McmsEndpoint, version: String? = null) { + perform("DELETE", endpoint, ApiQuery.EMPTY, null, version) + } + + /** Paginates by the `next` cursor; page size 100 (field guide §3.4). */ + suspend fun getAll( + endpoint: McmsEndpoint, + baseQuery: ApiQuery = ApiQuery.EMPTY, + pageLimit: Int = 100, + version: String? = null, + idOf: (JsonElement) -> String?, + ): List { + val all = mutableListOf() + var cursor: String? = null + do { + val q = baseQuery.copy(limit = pageLimit, next = cursor) + val page = getList(endpoint, q, version) + all += page + cursor = if (page.size == pageLimit) page.lastOrNull()?.let(idOf) else null + } while (cursor != null) + return all + } + + private data class Resp(val code: Int, val body: String) + + private fun buildUrl(endpoint: McmsEndpoint, query: ApiQuery, version: String?): HttpUrl { + val base = config.baseUrl.toHttpUrlOrNull() ?: throw ApiError.InvalidUrl + val builder = base.newBuilder() + // Raw ids; addPathSegments encodes once and keeps ':' in MAC ids raw. + builder.addPathSegments(endpoint.path(version ?: config.apiVersion).removePrefix("/")) + for ((name, value) in query.items()) builder.addQueryParameter(name, value) + return builder.build() + } + + private suspend fun perform(method: String, endpoint: McmsEndpoint, query: ApiQuery, bodyJson: String?, version: String?): Resp { + val url = buildUrl(endpoint, query, version) + val reqBody = when { + bodyJson != null -> bodyJson.toRequestBody(jsonMedia) + method == "POST" || method == "PUT" || method == "PATCH" -> ByteArray(0).toRequestBody() + else -> null + } + val builder = Request.Builder().url(url) + .header("Accept", "application/json") + .header("Referer", config.refererValue) + .method(method, reqBody) + if (bodyJson != null) builder.header("Content-Type", "application/json") + if (method != "GET") session.csrfToken()?.let { builder.header("X-CSRFToken", it) } + + val response = try { + client.newCall(builder.build()).await() + } catch (e: SSLException) { + throw ApiError.ServerTrust + } catch (e: IOException) { + throw ApiError.Transport(e.message ?: "request failed") + } + val code = response.code + val body = response.body?.string() ?: "" + response.close() + + if (code !in 200..299) { + val details = runCatching { MCMSJson.parseToJsonElement(body)["details"] }.getOrNull() + val error = ApiError.fromStatus(code, details) + if (error.requiresReauthentication) onAuthExpired?.invoke() + throw error + } + return Resp(code, body) + } + + private fun unwrapData(resp: Resp): JsonElement { + if (resp.body.isBlank()) return JsonNull + val root = MCMSJson.parseToJsonElement(resp.body) + val status = root["status"].string + if (status != null && !status.equals("success", ignoreCase = true)) { + throw ApiError.ApiFailure(root["details"]) + } + return root["data"] ?: JsonNull + } +} diff --git a/app/src/main/java/no/svorka/mcms/core/ApiConfiguration.kt b/app/src/main/java/no/svorka/mcms/core/ApiConfiguration.kt new file mode 100644 index 0000000..11d26ad --- /dev/null +++ b/app/src/main/java/no/svorka/mcms/core/ApiConfiguration.kt @@ -0,0 +1,59 @@ +package no.svorka.mcms.core + +import kotlinx.serialization.Serializable +import java.net.URI + +/** How to evaluate the server's TLS certificate. */ +@Serializable +sealed class ServerTrustPolicy { + /** Default platform trust evaluation. */ + @Serializable + data object System : ServerTrustPolicy() + + /** Accept any certificate presented by this exact host (self-signed lab boxes). */ + @Serializable + data class AllowSelfSignedForHost(val host: String) : ServerTrustPolicy() + + /** Trust only if the leaf public-key SHA-256 (base64) matches one of these. */ + @Serializable + data class PinPublicKeySha256(val pins: List, val host: String) : ServerTrustPolicy() +} + +/** + * Connection configuration for the MCMS REST API. URL-driven so the same build + * works direct or via reverse proxy. `baseUrl` includes the `/api` prefix, e.g. + * `https://mcms.lab.svorka.net/api`. + */ +@Serializable +data class ApiConfiguration( + val baseUrl: String, + val apiVersion: String = "v1", + val databaseId: String? = null, + val refererOverride: String? = null, + val sessionCookieName: String = "sessionid", + val csrfCookieName: String = "csrftoken", + val trustPolicy: ServerTrustPolicy = ServerTrustPolicy.System, + val timeoutSeconds: Long = 30, +) { + val refererValue: String get() = refererOverride ?: baseUrl + val host: String? get() = runCatching { URI(baseUrl).host }.getOrNull() + + companion object { + /** + * Normalises a user-entered URL: requires scheme + host, trims a trailing + * slash, and defaults the path to `/api` when none is given. Returns null + * if it isn't a usable URL. + */ + fun normalizedBaseUrl(raw: String): String? { + val trimmed = raw.trim() + val uri = runCatching { URI(trimmed) }.getOrNull() ?: return null + val scheme = uri.scheme ?: return null + val host = uri.host?.takeIf { it.isNotEmpty() } ?: return null + var path = uri.path ?: "" + while (path.endsWith("/")) path = path.dropLast(1) + if (path.isEmpty()) path = "/api" + val port = if (uri.port >= 0) ":${uri.port}" else "" + return "$scheme://$host$port$path" + } + } +} diff --git a/app/src/main/java/no/svorka/mcms/core/ApiError.kt b/app/src/main/java/no/svorka/mcms/core/ApiError.kt new file mode 100644 index 0000000..aefd979 --- /dev/null +++ b/app/src/main/java/no/svorka/mcms/core/ApiError.kt @@ -0,0 +1,38 @@ +package no.svorka.mcms.core + +import kotlinx.serialization.json.JsonElement + +/** Typed errors surfaced by [ApiClient]. */ +sealed class ApiError(message: String) : Exception(message) { + data object InvalidUrl : ApiError("The request URL was invalid.") + data class Transport(val detail: String) : ApiError("Network error: $detail") + data object ServerTrust : ApiError("The server's TLS certificate could not be trusted.") + data class Encoding(val detail: String) : ApiError("Failed to encode request: $detail") + data class Decoding(val detail: String) : ApiError("Failed to read server response: $detail") + + data class BadRequest(val details: JsonElement?) : ApiError("Request rejected (400). ${details.compactDescription()}") + data object Unauthorized : ApiError("Not authenticated (401). Please sign in again.") + data class Forbidden(val details: JsonElement?) : ApiError("Forbidden (403) — your account may not have permission for this action. ${details.compactDescription()}") + data object NotFound : ApiError("Resource not found (404).") + data class Conflict(val details: JsonElement?) : ApiError("Conflict (409) — ID already in use. ${details.compactDescription()}") + data object UnsupportedMediaType : ApiError("Unsupported media type (415).") + data class Server(val details: JsonElement?) : ApiError("Server error (500). ${details.compactDescription()}") + data class ApiFailure(val details: JsonElement?) : ApiError("Request failed. ${details.compactDescription()}") + data class UnexpectedStatus(val code: Int, val details: JsonElement?) : ApiError("Unexpected response ($code). ${details.compactDescription()}") + + /** Only 401 should bounce to login; a 403 is usually a permission/CSRF issue. */ + val requiresReauthentication: Boolean get() = this is Unauthorized + + companion object { + fun fromStatus(code: Int, details: JsonElement?): ApiError = when (code) { + 400 -> BadRequest(details) + 401 -> Unauthorized + 403 -> Forbidden(details) + 404 -> NotFound + 409 -> Conflict(details) + 415 -> UnsupportedMediaType + 500 -> Server(details) + else -> UnexpectedStatus(code, details) + } + } +} diff --git a/app/src/main/java/no/svorka/mcms/core/ApiQuery.kt b/app/src/main/java/no/svorka/mcms/core/ApiQuery.kt new file mode 100644 index 0000000..9b8b8a5 --- /dev/null +++ b/app/src/main/java/no/svorka/mcms/core/ApiQuery.kt @@ -0,0 +1,48 @@ +package no.svorka.mcms.core + +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +/** + * Builder for the MCMS list-endpoint query parameters. `query`/`projection`/ + * `sort` join as comma-separated `key=value` strings; `start-time`/`end-time` + * use the UTC `yyyy-MM-dd HH:mm:ss` format. + */ +data class ApiQuery( + val query: Map = emptyMap(), + val projection: Map = emptyMap(), + val sort: Map = emptyMap(), + val limit: Int? = null, + val skip: Int? = null, + val next: String? = null, + val distinct: String? = null, + val startTime: Date? = null, + val endTime: Date? = null, +) { + fun items(): List> { + val out = mutableListOf>() + if (query.isNotEmpty()) out += "query" to query.entries.joinToString(",") { "${it.key}=${it.value}" } + if (projection.isNotEmpty()) out += "projection" to projection.entries.joinToString(",") { "${it.key}=${it.value}" } + if (sort.isNotEmpty()) out += "sort" to sort.entries.joinToString(",") { "${it.key}=${it.value}" } + limit?.let { out += "limit" to it.toString() } + skip?.let { out += "skip" to it.toString() } + next?.let { out += "next" to it } + distinct?.let { out += "distinct" to it } + startTime?.let { out += "start-time" to timeFormatter.format(it) } + endTime?.let { out += "end-time" to timeFormatter.format(it) } + return out + } + + companion object { + val EMPTY = ApiQuery() + + private val timeFormatter = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + + fun window(lastHours: Int, limit: Int? = null): ApiQuery = + ApiQuery(startTime = Date(System.currentTimeMillis() - lastHours * 3_600_000L), limit = limit) + } +} diff --git a/app/src/main/java/no/svorka/mcms/core/AuthModels.kt b/app/src/main/java/no/svorka/mcms/core/AuthModels.kt new file mode 100644 index 0000000..fcc76dd --- /dev/null +++ b/app/src/main/java/no/svorka/mcms/core/AuthModels.kt @@ -0,0 +1,9 @@ +package no.svorka.mcms.core + +import kotlinx.serialization.json.JsonElement + +/** `GET /ponmgr/version/` — best-effort display version. */ +class VersionInfo(val raw: JsonElement) { + val displayVersion: String? + get() = raw["version"].string ?: raw["Version"].string ?: raw["ponmgr"]["version"].string +} diff --git a/app/src/main/java/no/svorka/mcms/core/Firmware.kt b/app/src/main/java/no/svorka/mcms/core/Firmware.kt new file mode 100644 index 0000000..0870d7e --- /dev/null +++ b/app/src/main/java/no/svorka/mcms/core/Firmware.kt @@ -0,0 +1,62 @@ +package no.svorka.mcms.core + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +/** One uploaded ONU firmware image from `GET /files/onu-firmware/`. */ +class FirmwareFile(val raw: JsonElement) { + val id: String get() = raw["_id"].string ?: filename ?: "?" + val filename: String? get() = raw["filename"].string ?: raw["_id"].string + val version: String? get() = raw["metadata"]["Version"].string + val manufacturer: String? get() = raw["metadata"]["Compatible Manufacturer"].string + val models: List get() = raw["metadata"]["Compatible Model"].array?.mapNotNull { it.string } ?: emptyList() + val sizeBytes: Double? get() = raw["length"].double + 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. */ + fun isCompatible(vendor: String?, equipmentId: String?): Boolean { + val mfg = manufacturer + if (vendor != null && !mfg.isNullOrEmpty() && !mfg.equals(vendor, ignoreCase = true)) return false + if (equipmentId == null || models.isEmpty()) return true + return models.any { it.equals(equipmentId, ignoreCase = true) } + } +} + +/** Procedure 7 (per-ONU firmware upgrade) as a pure transform. */ +object FirmwareUpgrade { + data class Result(val document: JsonElement, val slot: Int) + + /** Stage `filename`/`version` in the ONU's INACTIVE bank and point at it, + * preserving every other field (full-doc PUT). Ptr 0→1, 1→0, else→1. */ + fun apply(config: JsonElement, filename: String, version: String): Result? { + val root = config as? JsonObject ?: return null + val onu = root["ONU"] as? JsonObject ?: return null + + val currentPtr = onu["FW Bank Ptr"].int ?: 65535 + val slot = if (currentPtr == 1) 0 else 1 + + val files = (onu["FW Bank Files"] as? JsonArray)?.toMutableList() ?: mutableListOf() + val versions = (onu["FW Bank Versions"] as? JsonArray)?.toMutableList() ?: mutableListOf() + while (files.size <= slot) files.add(JsonPrimitive("")) + while (versions.size <= slot) versions.add(JsonPrimitive("")) + files[slot] = JsonPrimitive(filename) + versions[slot] = JsonPrimitive(version) + + val newOnu = buildJsonObject { + onu.forEach { (k, v) -> put(k, v) } + put("FW Bank Files", JsonArray(files)) + put("FW Bank Versions", JsonArray(versions)) + put("FW Bank Ptr", JsonPrimitive(slot)) + } + val newRoot = buildJsonObject { + root.forEach { (k, v) -> put(k, v) } + put("ONU", newOnu) + } + return Result(newRoot, slot) + } +} diff --git a/app/src/main/java/no/svorka/mcms/core/Json.kt b/app/src/main/java/no/svorka/mcms/core/Json.kt new file mode 100644 index 0000000..a9c6618 --- /dev/null +++ b/app/src/main/java/no/svorka/mcms/core/Json.kt @@ -0,0 +1,55 @@ +package no.svorka.mcms.core + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.intOrNull + +/** + * Shared lenient JSON. MCMS documents are heterogeneous Mongo docs, so we work + * with raw [JsonElement] trees (the Kotlin analogue of the iOS `JSONValue`) and + * read fields through the accessors below rather than modelling every field. + */ +val MCMSJson: Json = Json { + ignoreUnknownKeys = true + isLenient = true + encodeDefaults = true +} + +/** Object-member access that tolerates a null receiver and non-object values. */ +operator fun JsonElement?.get(key: String): JsonElement? = + (this as? JsonObject)?.get(key) + +val JsonElement?.string: String? + get() = (this as? JsonPrimitive)?.takeIf { it.isString }?.content + +val JsonElement?.int: Int? + get() = (this as? JsonPrimitive)?.let { it.intOrNull ?: it.doubleOrNull?.toInt() } + +val JsonElement?.double: Double? + get() = (this as? JsonPrimitive)?.let { it.doubleOrNull ?: it.intOrNull?.toDouble() } + +val JsonElement?.bool: Boolean? + get() = (this as? JsonPrimitive)?.booleanOrNull + +val JsonElement?.array: List? + get() = this as? JsonArray + +val JsonElement?.obj: Map? + get() = this as? JsonObject + +val JsonElement?.isObject: Boolean + get() = this is JsonObject + +/** A compact human rendering for error/detail surfaces. */ +fun JsonElement?.compactDescription(): String = when (val e = this) { + null, JsonNull -> "null" + is JsonPrimitive -> e.content + is JsonArray -> e.joinToString(", ") { it.compactDescription() } + is JsonObject -> e.entries.joinToString("; ") { "${it.key}: ${it.value.compactDescription()}" } +} diff --git a/app/src/main/java/no/svorka/mcms/core/KeychainStore.kt b/app/src/main/java/no/svorka/mcms/core/KeychainStore.kt new file mode 100644 index 0000000..0fc8f16 --- /dev/null +++ b/app/src/main/java/no/svorka/mcms/core/KeychainStore.kt @@ -0,0 +1,29 @@ +package no.svorka.mcms.core + +import android.content.Context +import android.content.SharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey + +/** + * Encrypted credential storage (Android Keystore-backed), the analogue of the + * iOS Keychain wrapper. Never store secrets in plain SharedPreferences. + */ +class KeychainStore(context: Context) { + private val prefs: SharedPreferences = run { + val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + EncryptedSharedPreferences.create( + context, + "mcms_credentials", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + } + + fun get(key: String): String? = prefs.getString(key, null) + fun set(key: String, value: String) { prefs.edit().putString(key, value).apply() } + fun delete(key: String) { prefs.edit().remove(key).apply() } +} diff --git a/app/src/main/java/no/svorka/mcms/core/McmsConnection.kt b/app/src/main/java/no/svorka/mcms/core/McmsConnection.kt new file mode 100644 index 0000000..dba703b --- /dev/null +++ b/app/src/main/java/no/svorka/mcms/core/McmsConnection.kt @@ -0,0 +1,17 @@ +package no.svorka.mcms.core + +/** Builds and holds the networking stack for one MCMS connection. */ +class McmsConnection(val config: ApiConfiguration) { + val session = SessionStore(config) + val client = ApiClient(config, session) + + val auth = AuthRepository(client, session, config) + val onu = OnuRepository(client) + val olt = OltRepository(client) + val controller = ControllerRepository(client) + + /** Fired when a request returns 401, so the app can route back to login. */ + var onAuthExpired: (() -> Unit)? + get() = client.onAuthExpired + set(value) { client.onAuthExpired = value } +} diff --git a/app/src/main/java/no/svorka/mcms/core/McmsEndpoint.kt b/app/src/main/java/no/svorka/mcms/core/McmsEndpoint.kt new file mode 100644 index 0000000..c946794 --- /dev/null +++ b/app/src/main/java/no/svorka/mcms/core/McmsEndpoint.kt @@ -0,0 +1,74 @@ +package no.svorka.mcms.core + +/** + * Versioned endpoint paths (relative to `baseUrl`). IDs are kept RAW here — the + * URL builder in [ApiClient] percent-encodes the whole path exactly once. + * Pre-encoding double-encoded MAC colons (`:` → `%3A` → `%253A`), which MCMS + * rejected as an invalid id. + */ +sealed class McmsEndpoint { + data object Authenticate : McmsEndpoint() + data object Logout : McmsEndpoint() + data object DatabaseSelection : McmsEndpoint() + data object Version : McmsEndpoint() + + data object ControllerConfigs : McmsEndpoint() + data class ControllerConfig(val id: String) : McmsEndpoint() + data class ControllerStats(val id: String) : McmsEndpoint() + data class ControllerLogs(val id: String) : McmsEndpoint() + + data object OltConfigs : McmsEndpoint() + data class OltConfig(val id: String) : McmsEndpoint() + data object OltStates : McmsEndpoint() + data class OltState(val id: String) : McmsEndpoint() + data class OltStats(val id: String) : McmsEndpoint() + data class OltLogs(val id: String) : McmsEndpoint() + data class OltReset(val id: String) : McmsEndpoint() + + data object OnuConfigs : McmsEndpoint() + data class OnuConfig(val id: String) : McmsEndpoint() + data object OnuStates : McmsEndpoint() + data class OnuState(val id: String) : McmsEndpoint() + data class OnuStats(val id: String) : McmsEndpoint() + data class OnuLogs(val id: String) : McmsEndpoint() + data object OnuAlarmHistories : McmsEndpoint() + data object OnuCpeStates : McmsEndpoint() + data class OnuReset(val id: String) : McmsEndpoint() + data object OnuFirmwareFiles : McmsEndpoint() + data class OnuCpe(val id: String) : McmsEndpoint() // versionless web helper + + fun path(version: String): String { + val v = "/$version" + return when (this) { + Authenticate -> "$v/users/authenticate/" + Logout -> "$v/users/logout/" + DatabaseSelection -> "$v/databases/selection/" + Version -> "$v/ponmgr/version/" + + ControllerConfigs -> "$v/controllers/configs/" + is ControllerConfig -> "$v/controllers/configs/$id/" + is ControllerStats -> "$v/controllers/stats/$id/" + is ControllerLogs -> "$v/controllers/logs/$id/" + + OltConfigs -> "$v/olts/configs/" + is OltConfig -> "$v/olts/configs/$id/" + OltStates -> "$v/olts/states/" + is OltState -> "$v/olts/states/$id/" + is OltStats -> "$v/olts/stats/$id/" + is OltLogs -> "$v/olts/logs/$id/" + is OltReset -> "$v/olts/$id/reset/" + + OnuConfigs -> "$v/onus/configs/" + is OnuConfig -> "$v/onus/configs/$id/" + OnuStates -> "$v/onus/states/" + is OnuState -> "$v/onus/states/$id/" + is OnuStats -> "$v/onus/stats/$id/" + is OnuLogs -> "$v/onus/logs/$id/" + OnuAlarmHistories -> "$v/onus/alarm-histories/" + OnuCpeStates -> "$v/onus/cpe-states/" + is OnuReset -> "$v/onus/$id/reset/" + OnuFirmwareFiles -> "$v/files/onu-firmware/" + is OnuCpe -> "/cpe/onu/$id/" + } + } +} diff --git a/app/src/main/java/no/svorka/mcms/core/Models.kt b/app/src/main/java/no/svorka/mcms/core/Models.kt new file mode 100644 index 0000000..9ac54dc --- /dev/null +++ b/app/src/main/java/no/svorka/mcms/core/Models.kt @@ -0,0 +1,212 @@ +package no.svorka.mcms.core + +import kotlinx.serialization.json.JsonElement + +// ───────────────────────── Enums ───────────────────────── + +/** ONU registration buckets from OLT-STATE `"ONU States"` (dev guide Procedure 2). */ +enum class OnuLifecycleState(val label: String) { + REGISTERED("Registered"), + UNSPECIFIED("Unspecified"), + UNPROVISIONED("Unprovisioned"), + DEREGISTERED("Deregistered"), + DYING_GASP("Dying Gasp"), + DISABLED("Disabled"), + DISALLOWED_ADMIN("Disallowed Admin"), + DISALLOWED_ERROR("Disallowed Error"), + DISALLOWED_REG_ID("Disallowed Reg ID"), + UNKNOWN("Unknown"); + + /** Registered or Unspecified counts as online (dev guide Procedure 2). */ + val isOnline: Boolean get() = this == REGISTERED || this == UNSPECIFIED + + companion object { + fun fromBucket(name: String): OnuLifecycleState = + entries.firstOrNull { it.label.equals(name, ignoreCase = true) } ?: UNKNOWN + } +} + +enum class AlarmSeverity(val rank: Int, val label: String) { + EMERGENCY(0, "emergency"), ALERT(1, "alert"), CRITICAL(2, "critical"), + ERROR(3, "error"), WARNING(4, "warning"), NOTICE(5, "notice"), + INFO(6, "info"), DEBUG(7, "debug"), UNKNOWN(8, "unknown"); + + companion object { + fun from(raw: String?): AlarmSeverity = + entries.firstOrNull { it.label.equals(raw, ignoreCase = true) } ?: UNKNOWN + } +} + +// ───────────────────────── ONU-STATE ───────────────────────── + +class OnuStateDoc(val raw: JsonElement) { + val id: String get() = raw["_id"].string ?: "?" + val serialNumber: String? get() = raw["_id"].string + /** Parent OLT MAC, from `OLT.MAC Address` (dev guide Procedure 2). */ + val parentOlt: String? get() = raw["OLT"]["MAC Address"].string + val lastUpdate: String? get() = raw["Time"].string + + /** Registration lives on OLT-STATE; resolved + stamped by the list/dashboard VMs. */ + var resolvedLifecycle: OnuLifecycleState? = null + val lifecycle: OnuLifecycleState get() = resolvedLifecycle ?: OnuLifecycleState.UNKNOWN + + /** Operator name from ONU-CFG; stamped by the VM. */ + var resolvedName: String? = null + val displayName: String get() = resolvedName?.takeIf { it.isNotEmpty() } ?: id + + private val onu get() = raw["ONU"] + val registrationState: String? get() = onu["Registration State"].string + val equipmentId: String? get() = onu["Equipment ID"].string + val vendor: String? get() = onu["Vendor"].string + val ponMode: String? get() = onu["PON Mode"].string + val onlineTime: String? get() = onu["Online Time"].string + val temperatureC: Double? get() = raw["GPON"]["Temperature"].double + + val fwVersion: String? get() = onu["FW Version"].string + val fwBankVersions: List get() = onu["FW Bank Versions"].array?.mapNotNull { it.string } ?: emptyList() + val fwBankPtr: Int? get() = onu["FW Bank Ptr"].int + val fwUpgradeStatus: JsonElement? get() = onu["FW Upgrade Status"]?.takeIf { it.obj?.isNotEmpty() == true } + + private fun ponStat(pon: String, key: String): Double? = raw["STATS"][pon][key].double + val rxOpticalDBm: Double? get() = ponStat("ONU-PON", "RX Optical Level") + val txOpticalDBm: Double? get() = ponStat("ONU-PON", "TX Optical Level") + val onuPreFec: Double? get() = ponStat("ONU-PON", "RX Pre-FEC BER") + val onuPostFec: Double? get() = ponStat("ONU-PON", "RX Post-FEC BER") + val oltPreFec: Double? get() = ponStat("OLT-PON", "RX Pre-FEC BER") + val oltPostFec: Double? get() = ponStat("OLT-PON", "RX Post-FEC BER") + + val uniPorts: List get() = (raw.obj ?: emptyMap()).entries + .filter { it.key.startsWith("UNI-") }.sortedBy { it.key } + .map { UniPort(it.key, it.value) } + + val ponServices: List get() = (raw["STATS"].obj ?: emptyMap()).entries + .filter { it.key.startsWith("OLT-PON Service ") }.sortedBy { it.key } + .map { PonServiceStats(it.key, it.value) } +} + +class UniPort(val name: String, val raw: JsonElement) { + val isEthernet: Boolean get() = name.contains("ETH", ignoreCase = true) + val enabled: Boolean? get() = raw["Enable"].bool + val speed: String? get() = raw["Speed"].string + val duplex: String? get() = raw["Duplex"].string + val state: String? get() = raw["State"].string +} + +class PonServiceStats(val name: String, val raw: JsonElement) { + val rxRateBps: Double? get() = raw["RX Rate bps"].double + val txRateBps: Double? get() = raw["TX Rate bps"].double + val rxTotalOctets: Double? get() = raw["RX Total Octets"].double + val txTotalOctets: Double? get() = raw["TX Total Octets"].double +} + +// ───────────────────────── ONU-CFG ───────────────────────── + +class OnuConfigDoc(val raw: JsonElement) { + val id: String get() = raw["_id"].string ?: "?" + val name: String? get() = raw["ONU"]["Name"].string +} + +// ───────────────────────── OLT-STATE / OLT-CFG ───────────────────────── + +class OltStateDoc(val raw: JsonElement) { + val id: String get() = raw["_id"].string ?: "?" + val lastUpdate: String? get() = raw["Time"].string + + var resolvedName: String? = null + val displayName: String get() = resolvedName?.takeIf { it.isNotEmpty() } ?: id + + val onuStatesByBucket: Map> get() { + val o = raw["ONU States"].obj ?: return emptyMap() + return o.mapValues { (_, v) -> v.array?.mapNotNull { it.string } ?: emptyList() } + } + val totalOnus: Int get() = onuStatesByBucket.values.sumOf { it.size } + val onlineOnus: Int get() = onuStatesByBucket.entries.sumOf { (bucket, ids) -> + if (OnuLifecycleState.fromBucket(bucket).isOnline) ids.size else 0 + } + val populatedBuckets: List>> get() = onuStatesByBucket + .filterValues { it.isNotEmpty() } + .map { OnuLifecycleState.fromBucket(it.key) to it.value.sorted() } + .sortedBy { it.first.ordinal } + + private val olt get() = raw["OLT"] + private val ponStats get() = raw["STATS"]["OLT-PON"] + val onlineTime: String? get() = olt["Online Time"].string + val firmwareVersion: String? get() = olt["FW Version"].string + val model: String? get() = olt["Model"].string + val ponMode: String? get() = olt["PON Mode"].string + + val statsTotalOnus: Int? get() = ponStats["Total ONUs Count"].int + val statsOnlineOnus: Int? get() = ponStats["Online ONUs Count"].int + val rxUtilPercent: Int? get() = ponStats["RX BW Total Util"].int + val txUtilPercent: Int? get() = ponStats["TX BW Total Util"].int + val rxRateBps: Double? get() = ponStats["RX BW Ethernet Rate bps"].double + val txRateBps: Double? get() = ponStats["TX BW Ethernet Rate bps"].double + + val asicTempC: Int? get() = raw["STATS"]["OLT-TEMP"]["ASIC"].int + val voltage: Double? get() = raw["STATS"]["OLT-ENV"]["Voltage"].double + + val laserShutdown: String? get() = olt["Laser Shutdown"].string + val isLaserOff: Boolean get() = laserShutdown?.let { !it.equals("Laser ON", ignoreCase = true) } ?: false + + val switchName: String? get() = raw["Switch"]["System Name"].string?.takeIf { it.isNotEmpty() } + val switchPort: String? get() = raw["Switch"]["Port ID"].string?.takeIf { it.isNotEmpty() } + val switchAddress: String? get() = raw["Switch"]["IPv4 Address"].string?.takeIf { it.isNotEmpty() } +} + +class OltConfigDoc(val raw: JsonElement) { + val id: String get() = raw["_id"].string ?: "?" + /** Operator label `OLT.Name` (NOT `NETCONF.Name`, which defaults to the MAC). */ + val name: String? get() = raw["OLT"]["Name"].string?.takeIf { it.isNotEmpty() } +} + +class ControllerConfigDoc(val raw: JsonElement) { + val id: String get() = raw["_id"].string ?: "?" +} + +// ───────────────────────── Alarms / logs / stats / CPE ───────────────────────── + +class AlarmHistoryDoc(val raw: JsonElement) { + val id: String get() = raw["_id"].string ?: "?" + val severity: AlarmSeverity get() = AlarmSeverity.from(raw["Severity"].string ?: raw["severity"].string) + val message: String? get() = raw["Message"].string ?: raw["message"].string + val raisedAt: String? get() = raw["Time"].string ?: raw["timestamp"].string +} + +class LogEntry(val raw: JsonElement) { + val id: String get() = raw["_id"].string ?: "?" + val severity: AlarmSeverity get() = AlarmSeverity.from(raw["Severity"].string ?: raw["severity"].string) + val message: String? get() = raw["Message"].string ?: raw["message"].string + val time: String? get() = raw["Time"].string ?: raw["timestamp"].string +} + +class StatSample(val raw: JsonElement) { + val id: String get() = raw["_id"].string ?: "?" + val timestamp: String? get() = raw["Time"].string ?: raw["timestamp"].string + val numericMetrics: List> get() = (raw.obj ?: emptyMap()) + .mapNotNull { (k, v) -> v.double?.let { k to it } }.sortedBy { it.first } +} + +/** A CPE behind an ONU with its DHCP lease — from the `/cpe/onu//` helper. */ +class CpeState(val raw: JsonElement) { + val id: String get() = raw["_id"].string ?: "?" + val mac: String? get() = raw["_id"].string + + val dhcpState: String? get() = raw["DHCP"]["Client State"].string?.takeIf { it.isNotEmpty() } + val ipv4: String? get() = raw["DHCP"]["Client IP Addr"].string?.takeIf { it.isNotEmpty() } + val dhcpServer: String? get() = raw["DHCP"]["Server ID"].string?.takeIf { it.isNotEmpty() } + val dhcpLeaseSeconds: Int? get() = raw["DHCP"]["Lease Time"].int + val circuitId: String? get() = raw["DHCP"]["Circuit ID"].string?.takeIf { it.isNotEmpty() } + val dhcpLastSuccess: String? get() = raw["DHCP"]["Last Success Time"].string?.takeIf { it.isNotEmpty() } + + val dhcpv6State: String? get() = raw["DHCPV6"]["Client State"].string?.takeIf { it.isNotEmpty() } + val ipv6: String? get() = raw["DHCPV6"]["Client IP Addr"].string?.takeIf { it.isNotEmpty() } + val ipv6ValidLifetime: String? get() = raw["DHCPV6"]["Valid Lifetime"].string?.takeIf { it.isNotEmpty() } + val ipv6Prefix: String? get() { + val entry = raw["DHCPV6"]["Prefix Delegation"].array?.firstOrNull() ?: return null + val prefixObj = entry["Prefixes"].array?.firstOrNull() ?: return null + val prefix = prefixObj["Prefix"].string ?: return null + val len = prefixObj["Prefix Length"].int ?: return null + return "$prefix/$len" + } + val hasLease: Boolean get() = ipv4 != null || ipv6 != null +} diff --git a/app/src/main/java/no/svorka/mcms/core/Networking.kt b/app/src/main/java/no/svorka/mcms/core/Networking.kt new file mode 100644 index 0000000..67c2402 --- /dev/null +++ b/app/src/main/java/no/svorka/mcms/core/Networking.kt @@ -0,0 +1,90 @@ +package no.svorka.mcms.core + +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.HttpUrl +import okhttp3.OkHttpClient +import java.security.SecureRandom +import java.security.cert.X509Certificate +import javax.net.ssl.SSLContext +import javax.net.ssl.X509TrustManager + +/** + * In-memory cookie jar (per host), so we can both replay cookies and read the + * CSRF cookie value. Sessions are discarded on logout/app-kill. + */ +class SessionCookieJar : CookieJar { + private val store = mutableMapOf>() + + override fun saveFromResponse(url: HttpUrl, cookies: List) { + val list = store.getOrPut(url.host) { mutableListOf() } + cookies.forEach { c -> + list.removeAll { it.name == c.name } + list.add(c) + } + } + + override fun loadForRequest(url: HttpUrl): List = + store[url.host]?.filter { it.matches(url) } ?: emptyList() + + fun valueMatching(host: String, base: String): String? = + store[host]?.firstOrNull { matches(it.name, base) }?.value + + fun hasCookie(host: String, base: String): Boolean = + store[host]?.any { matches(it.name, base) } == true + + fun clear(host: String) { store.remove(host) } + + // Modern MCMS builds emit `__Host-`/`__Secure-` prefixed cookies; accept either. + private fun matches(name: String, base: String) = + name == base || name == "__Host-$base" || name == "__Secure-$base" +} + +/** Session state for one connection: the cookie jar + CSRF token access. */ +class SessionStore(private val config: ApiConfiguration) { + val cookieJar = SessionCookieJar() + var isAuthenticated = false + private set + + fun markAuthenticated() { isAuthenticated = true } + + /** CSRF token to echo as the `X-CSRFToken` header. Null before login. */ + fun csrfToken(): String? = config.host?.let { cookieJar.valueMatching(it, config.csrfCookieName) } + + val hasSessionCookie: Boolean + get() = config.host?.let { cookieJar.hasCookie(it, config.sessionCookieName) } == true + + fun clear() { + config.host?.let { cookieJar.clear(it) } + isAuthenticated = false + } +} + +/** Applies a [ServerTrustPolicy] to an OkHttp client builder. */ +object ServerTrust { + fun apply(builder: OkHttpClient.Builder, policy: ServerTrustPolicy) { + when (policy) { + is ServerTrustPolicy.System -> Unit // default platform trust + is ServerTrustPolicy.AllowSelfSignedForHost -> { + // Accept ANY certificate, but the hostname verifier restricts it to + // the one allowed host (matches the iOS self-signed escape hatch). + val trustAll = object : X509TrustManager { + override fun checkClientTrusted(chain: Array?, authType: String?) {} + override fun checkServerTrusted(chain: Array?, authType: String?) {} + override fun getAcceptedIssuers(): Array = emptyArray() + } + val ctx = SSLContext.getInstance("TLS").apply { + init(null, arrayOf(trustAll), SecureRandom()) + } + builder.sslSocketFactory(ctx.socketFactory, trustAll) + builder.hostnameVerifier { hostname, _ -> hostname.equals(policy.host, ignoreCase = true) } + } + is ServerTrustPolicy.PinPublicKeySha256 -> { + val pinner = okhttp3.CertificatePinner.Builder().apply { + policy.pins.forEach { add(policy.host, "sha256/$it") } + }.build() + builder.certificatePinner(pinner) + } + } + } +} diff --git a/app/src/main/java/no/svorka/mcms/core/Repositories.kt b/app/src/main/java/no/svorka/mcms/core/Repositories.kt new file mode 100644 index 0000000..085a116 --- /dev/null +++ b/app/src/main/java/no/svorka/mcms/core/Repositories.kt @@ -0,0 +1,132 @@ +package no.svorka.mcms.core + +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +class AuthRepository( + private val client: ApiClient, + private val session: SessionStore, + private val config: ApiConfiguration, +) { + suspend fun login(email: String, password: String) { + val body = buildJsonObject { put("email", email); put("password", password) } + // Login body is unwrapped (matches the official 6.2 dev guide). + client.send("POST", McmsEndpoint.Authenticate, body, wrapInData = false) + session.markAuthenticated() + config.databaseId?.let { selectDatabase(it) } + } + + suspend fun selectDatabase(id: String) { + // `{ "data": "" }` — a bare string. + client.send("PUT", McmsEndpoint.DatabaseSelection, JsonPrimitive(id), wrapInData = true) + } + + suspend fun logout() { + runCatching { client.action("GET", McmsEndpoint.Logout) } + session.clear() + } + + suspend fun version(): VersionInfo = VersionInfo(client.getElement(McmsEndpoint.Version)) +} + +class OnuRepository(private val client: ApiClient) { + suspend fun states(query: ApiQuery = ApiQuery.EMPTY): List = + client.getList(McmsEndpoint.OnuStates, query).map { OnuStateDoc(it) } + + suspend fun allStates(): List = + client.getAll(McmsEndpoint.OnuStates) { it["_id"].string }.map { OnuStateDoc(it) } + + suspend fun state(id: String): OnuStateDoc = OnuStateDoc(client.getElement(McmsEndpoint.OnuState(id))) + suspend fun config(id: String): OnuConfigDoc = OnuConfigDoc(client.getElement(McmsEndpoint.OnuConfig(id))) + + /** id → operator name via projection (only `ONU.Name` comes back). */ + suspend fun nameMap(): Map { + val q = ApiQuery(projection = mapOf("ONU.Name" to 1)) + return client.getAll(McmsEndpoint.OnuConfigs, baseQuery = q) { it["_id"].string } + .map { OnuConfigDoc(it) } + .mapNotNull { c -> c.name?.takeIf { it.isNotEmpty() }?.let { c.id to it } } + .toMap() + } + + suspend fun stats(id: String, lastHours: Int = 24): List = + client.getList(McmsEndpoint.OnuStats(id), ApiQuery.window(lastHours)).map { StatSample(it) } + + suspend fun logs(id: String, lastHours: Int = 24): List = + client.getList(McmsEndpoint.OnuLogs(id), ApiQuery.window(lastHours)).map { LogEntry(it) } + + suspend fun alarmHistories(query: ApiQuery = ApiQuery.EMPTY): List = + client.getList(McmsEndpoint.OnuAlarmHistories, query).map { AlarmHistoryDoc(it) } + + /** CPE DHCP state from the `/cpe/onu//` helper — a bare `[code, cpe…]` array. */ + suspend fun cpes(id: String): List = + client.getRaw(McmsEndpoint.OnuCpe(id)).array?.filter { it.isObject }?.map { CpeState(it) } ?: emptyList() + + suspend fun reset(id: String) { client.action("PUT", McmsEndpoint.OnuReset(id)) } + + suspend fun firmwareFiles(): List = + client.getList(McmsEndpoint.OnuFirmwareFiles).map { FirmwareFile(it) } + + /** Procedure 7: stage firmware in the inactive bank. Returns the slot. Service-affecting. */ + suspend fun upgradeFirmware(id: String, file: FirmwareFile): Int { + val filename = file.filename ?: throw ApiError.Encoding("Firmware file is missing a filename.") + val version = file.version ?: throw ApiError.Encoding("Firmware file is missing a version.") + val current = config(id) + val result = FirmwareUpgrade.apply(current.raw, filename, version) + ?: throw ApiError.Encoding("Unexpected ONU-CFG shape — firmware write aborted.") + client.send("PUT", McmsEndpoint.OnuConfig(id), result.document, wrapInData = true) + return result.slot + } +} + +class OltRepository(private val client: ApiClient) { + suspend fun states(query: ApiQuery = ApiQuery.EMPTY): List = + client.getList(McmsEndpoint.OltStates, query).map { OltStateDoc(it) } + + suspend fun allStates(): List = + client.getAll(McmsEndpoint.OltStates) { it["_id"].string }.map { OltStateDoc(it) } + + suspend fun state(id: String): OltStateDoc = OltStateDoc(client.getElement(McmsEndpoint.OltState(id))) + + suspend fun allConfigs(): List = + client.getAll(McmsEndpoint.OltConfigs) { it["_id"].string }.map { OltConfigDoc(it) } + + suspend fun nameMap(): Map { + val q = ApiQuery(projection = mapOf("OLT.Name" to 1)) + return client.getAll(McmsEndpoint.OltConfigs, baseQuery = q) { it["_id"].string } + .map { OltConfigDoc(it) } + .mapNotNull { c -> c.name?.takeIf { it.isNotEmpty() && it != c.id }?.let { c.id to it } } + .toMap() + } + + suspend fun reset(id: String) { client.action("PUT", McmsEndpoint.OltReset(id)) } + + suspend fun registrationMap(): Map = + OltRepository.registrationMap(allStates()) + + companion object { + /** Invert OLT-STATE buckets → onuId: state; prefer non-Registered on duplicate (§3.11). */ + fun registrationMap(olts: List): Map { + val map = mutableMapOf() + for (olt in olts) { + for ((bucket, ids) in olt.onuStatesByBucket) { + val state = OnuLifecycleState.fromBucket(bucket) + for (id in ids) { + val existing = map[id] + when { + existing == null -> map[id] = state + existing == OnuLifecycleState.REGISTERED && state != OnuLifecycleState.REGISTERED -> map[id] = state + } + } + } + } + return map + } + } +} + +class ControllerRepository(private val client: ApiClient) { + suspend fun allConfigs(): List = + client.getAll(McmsEndpoint.ControllerConfigs) { it["_id"].string }.map { ControllerConfigDoc(it) } +} diff --git a/app/src/main/java/no/svorka/mcms/core/Util.kt b/app/src/main/java/no/svorka/mcms/core/Util.kt new file mode 100644 index 0000000..a3dfb20 --- /dev/null +++ b/app/src/main/java/no/svorka/mcms/core/Util.kt @@ -0,0 +1,70 @@ +package no.svorka.mcms.core + +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +/** + * Parses/renders MCMS timestamps: UTC with a literal space, e.g. + * `2026-05-04 21:51:57.394127`. Try microsecond precision first, then seconds. + */ +object MongoTimestamp { + private val withMicros = utc("yyyy-MM-dd HH:mm:ss.SSSSSS") + private val seconds = utc("yyyy-MM-dd HH:mm:ss") + private val display = SimpleDateFormat("d MMM yyyy, HH:mm", Locale.getDefault()) + + private fun utc(pattern: String) = SimpleDateFormat(pattern, Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + isLenient = false + } + + fun parse(s: String?): Date? { + if (s == null) return null + return runCatching { withMicros.parse(s) }.getOrNull() + ?: runCatching { seconds.parse(s) }.getOrNull() + } + + fun displayString(s: String?): String? { + if (s == null) return null + val d = parse(s) ?: return s + return display.format(d) + } + + /** Uptime etc., computed from two MCMS timestamps so it uses the device clock. */ + fun duration(from: String?, to: String?): String? { + val start = parse(from) ?: return null + val end = parse(to) ?: return null + val total = (end.time - start.time) / 1000 + if (total < 0) return null + val days = total / 86400 + val hours = (total % 86400) / 3600 + val mins = (total % 3600) / 60 + return when { + days > 0 -> "${days}d ${hours}h" + hours > 0 -> "${hours}h ${mins}m" + else -> "${mins}m" + } + } +} + +/** Compact human formatting for counters in the UI. */ +object Format { + fun bps(value: Double?): String { + if (value == null || value < 0) return "—" + val units = listOf("bps", "kbps", "Mbps", "Gbps", "Tbps") + var x = value; var i = 0 + while (x >= 1000 && i < units.size - 1) { x /= 1000; i++ } + return if (i == 0 || x >= 100) "${x.toLong()} ${units[i]}" else "${"%.1f".format(x)} ${units[i]}" + } + + fun bytes(value: Double?): String { + if (value == null || value < 0) return "—" + val units = listOf("B", "KB", "MB", "GB", "TB", "PB") + var x = value; var i = 0 + while (x >= 1024 && i < units.size - 1) { x /= 1024; i++ } + return if (i == 0 || x >= 100) "${x.toLong()} ${units[i]}" else "${"%.1f".format(x)} ${units[i]}" + } + + fun percent(value: Int?): String = value?.let { "$it%" } ?: "—" +} diff --git a/app/src/main/java/no/svorka/mcms/ui/AppState.kt b/app/src/main/java/no/svorka/mcms/ui/AppState.kt new file mode 100644 index 0000000..6121922 --- /dev/null +++ b/app/src/main/java/no/svorka/mcms/ui/AppState.kt @@ -0,0 +1,83 @@ +package no.svorka.mcms.ui + +import android.content.Context +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import no.svorka.mcms.core.ApiConfiguration +import no.svorka.mcms.core.ApiError +import no.svorka.mcms.core.KeychainStore +import no.svorka.mcms.core.MCMSJson +import no.svorka.mcms.core.McmsConnection + +/** + * Top-level app state: owns the active [McmsConnection], persists the (non-secret) + * [ApiConfiguration] to SharedPreferences, and keeps credentials in the Keystore. + */ +class AppState(context: Context) { + private val prefs = context.getSharedPreferences("mcms.app", Context.MODE_PRIVATE) + private val keychain = KeychainStore(context) + + var configuration by mutableStateOf(null) + private set + var connection by mutableStateOf(null) + private set + var isAuthenticated by mutableStateOf(false) + + val isConfigured: Boolean get() = configuration != null + + init { loadConfiguration() } + + fun configure(config: ApiConfiguration) { + configuration = config + connection = makeConnection(config) + isAuthenticated = false + prefs.edit().putString("config", MCMSJson.encodeToString(ApiConfiguration.serializer(), config)).apply() + } + + private fun makeConnection(config: ApiConfiguration): McmsConnection { + val conn = McmsConnection(config) + conn.onAuthExpired = { isAuthenticated = false } + return conn + } + + private fun loadConfiguration() { + val json = prefs.getString("config", null) ?: return + runCatching { MCMSJson.decodeFromString(ApiConfiguration.serializer(), json) }.getOrNull()?.let { + configuration = it + connection = makeConnection(it) + } + } + + val savedEmail: String? get() = keychain.get("email") + val savedPassword: String? get() = keychain.get("password") + + suspend fun signIn(email: String, password: String, remember: Boolean) { + val conn = connection ?: throw ApiError.Transport("No connection configured") + conn.auth.login(email, password) + if (remember) { + keychain.set("email", email) + keychain.set("password", password) + } + isAuthenticated = true + } + + suspend fun signOut() { + connection?.auth?.logout() + isAuthenticated = false + } + + /** Reachability probe: a 401/403/404 means the host answered, which is success. */ + suspend fun probe(config: ApiConfiguration): String { + val conn = McmsConnection(config) + return try { + val version = conn.auth.version() + version.displayVersion?.let { "PON Manager $it" } ?: "Reachable" + } catch (e: ApiError) { + when (e) { + is ApiError.Unauthorized, is ApiError.Forbidden, ApiError.NotFound -> "Reachable — sign in to continue" + else -> throw e + } + } + } +} diff --git a/app/src/main/java/no/svorka/mcms/ui/AuthScreens.kt b/app/src/main/java/no/svorka/mcms/ui/AuthScreens.kt new file mode 100644 index 0000000..7c559bc --- /dev/null +++ b/app/src/main/java/no/svorka/mcms/ui/AuthScreens.kt @@ -0,0 +1,171 @@ +package no.svorka.mcms.ui + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import no.svorka.mcms.core.ApiConfiguration +import no.svorka.mcms.core.ServerTrustPolicy + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LoginScreen(app: AppState, onOpenSettings: () -> Unit) { + var email by rememberSaveable { mutableStateOf(app.savedEmail ?: "") } + var password by rememberSaveable { mutableStateOf(app.savedPassword ?: "") } + var remember by rememberSaveable { mutableStateOf(true) } + var phase by remember { mutableStateOf(LoadPhase.Idle) } + val scope = rememberCoroutineScope() + + Scaffold(topBar = { + TopAppBar( + title = { Text("PON Go") }, + actions = { IconButton(onClick = onOpenSettings) { Icon(Icons.Filled.Settings, contentDescription = "Settings") } }, + ) + }) { padding -> + Column(Modifier.padding(padding).padding(16.dp).fillMaxSize()) { + OutlinedTextField( + value = email, onValueChange = { email = it }, label = { Text("Email") }, singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = password, onValueChange = { password = it }, label = { Text("Password") }, singleLine = true, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth(), + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = remember, onCheckedChange = { remember = it }) + Text("Remember me") + } + app.configuration?.host?.let { + Text("Connecting to $it", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Spacer(Modifier.height(16.dp)) + Button( + onClick = { + phase = LoadPhase.Loading + scope.launch { + phase = try { app.signIn(email, password, remember); LoadPhase.Loaded } + catch (e: Throwable) { LoadPhase.Failed(e.uiMessage()) } + } + }, + enabled = email.isNotBlank() && password.isNotBlank() && phase != LoadPhase.Loading, + modifier = Modifier.fillMaxWidth(), + ) { + if (phase == LoadPhase.Loading) CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) + else Text("Sign in") + } + (phase as? LoadPhase.Failed)?.let { + Spacer(Modifier.height(8.dp)) + Text(it.message, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen(app: AppState, initialSetup: Boolean, onClose: () -> Unit) { + var url by rememberSaveable { mutableStateOf(app.configuration?.baseUrl ?: "https://") } + var apiVersion by rememberSaveable { mutableStateOf(app.configuration?.apiVersion ?: "v1") } + var dbId by rememberSaveable { mutableStateOf(app.configuration?.databaseId ?: "") } + var allowSelfSigned by rememberSaveable { + mutableStateOf(app.configuration?.trustPolicy is ServerTrustPolicy.AllowSelfSignedForHost) + } + var testPhase by remember { mutableStateOf(LoadPhase.Idle) } + var testResult by remember { mutableStateOf(null) } + var saveError by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + + fun makeConfig(): ApiConfiguration? { + val base = ApiConfiguration.normalizedBaseUrl(url) ?: return null + val host = ApiConfiguration(baseUrl = base).host + val policy = if (allowSelfSigned && host != null) ServerTrustPolicy.AllowSelfSignedForHost(host) + else ServerTrustPolicy.System + return ApiConfiguration( + baseUrl = base, apiVersion = apiVersion.trim(), + databaseId = dbId.ifBlank { null }, trustPolicy = policy, + ) + } + val canSave = ApiConfiguration.normalizedBaseUrl(url) != null && apiVersion.isNotBlank() + + Scaffold(topBar = { + TopAppBar( + title = { Text(if (initialSetup) "Set up connection" else "Settings") }, + navigationIcon = { if (!initialSetup) TextButton(onClick = onClose) { Text("Close") } }, + actions = { + TextButton( + enabled = canSave, + onClick = { + val config = makeConfig() + if (config == null) { saveError = "Enter a valid URL including scheme (https://)." } + else { saveError = null; app.configure(config); if (!initialSetup) onClose() } + }, + ) { Text("Save") } + }, + ) + }) { padding -> + Column( + Modifier.padding(padding).padding(16.dp).fillMaxSize().verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedTextField( + value = url, onValueChange = { url = it }, label = { Text("Server URL") }, singleLine = true, + supportingText = { Text("Host or origin, e.g. https://mcms.lab.svorka.net — “/api” is appended automatically.") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = apiVersion, onValueChange = { apiVersion = it }, label = { Text("API version") }, singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = dbId, onValueChange = { dbId = it }, label = { Text("Database ID (optional)") }, singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Switch(checked = allowSelfSigned, onCheckedChange = { allowSelfSigned = it }) + Spacer(Modifier.width(8.dp)) + Text("Allow self-signed certificate") + } + + Button( + enabled = canSave && testPhase != LoadPhase.Loading, + onClick = { + val config = makeConfig() ?: return@Button + testPhase = LoadPhase.Loading; testResult = null + scope.launch { + try { testResult = app.probe(config); testPhase = LoadPhase.Loaded } + catch (e: Throwable) { testResult = e.uiMessage(); testPhase = LoadPhase.Failed(e.uiMessage()) } + } + }, + ) { + if (testPhase == LoadPhase.Loading) CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) + else Text("Test connection") + } + testResult?.let { + val isErr = testPhase is LoadPhase.Failed + Text(it, color = if (isErr) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall) + } + saveError?.let { Text(it, color = MaterialTheme.colorScheme.error) } + + if (!initialSetup) { + Spacer(Modifier.height(8.dp)) + OutlinedButton(onClick = { scope.launch { app.signOut() } }) { Text("Sign out") } + } + } + } +} diff --git a/app/src/main/java/no/svorka/mcms/ui/Components.kt b/app/src/main/java/no/svorka/mcms/ui/Components.kt new file mode 100644 index 0000000..8f13294 --- /dev/null +++ b/app/src/main/java/no/svorka/mcms/ui/Components.kt @@ -0,0 +1,118 @@ +package no.svorka.mcms.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +@Composable +fun StateBadge(text: String, color: Color) { + Surface(color = color.copy(alpha = 0.18f), shape = RoundedCornerShape(50)) { + Text( + text, + color = color, + style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.SemiBold), + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + ) + } +} + +@Composable +fun LoadingView(label: String = "Loading…") { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator() + Spacer(Modifier.height(12.dp)) + Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } +} + +@Composable +fun ErrorState(message: String, onRetry: (() -> Unit)? = null) { + Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon(Icons.Filled.Warning, contentDescription = null, tint = MaterialTheme.colorScheme.error) + Spacer(Modifier.height(8.dp)) + Text("Something went wrong", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(4.dp)) + Text(message, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + if (onRetry != null) { + Spacer(Modifier.height(16.dp)) + Button(onClick = onRetry) { Text("Retry") } + } + } + } +} + +@Composable +fun CountTile(title: String, value: Int, modifier: Modifier = Modifier) { + Card(modifier = modifier) { + Column(Modifier.padding(14.dp)) { + Text(title, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.height(6.dp)) + Text("$value", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold) + } + } +} + +@Composable +fun SectionCard(title: String, content: @Composable ColumnScope.() -> Unit) { + Column { + Text(title, style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(bottom = 8.dp)) + Card { Column(Modifier.padding(14.dp), content = content) } + } +} + +@Composable +fun KeyValueRow(label: String, value: String) { + Row( + Modifier.fillMaxWidth().padding(vertical = 6.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(label, style = MaterialTheme.typography.bodyMedium) + Text(value, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +@Composable +fun BackButton(onBack: () -> Unit) { + IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") } +} + +@Composable +fun ListRow(onClick: (() -> Unit)? = null, content: @Composable RowScope.() -> Unit) { + Row( + Modifier.fillMaxWidth() + .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + content = content, + ) +} diff --git a/app/src/main/java/no/svorka/mcms/ui/DashboardScreen.kt b/app/src/main/java/no/svorka/mcms/ui/DashboardScreen.kt new file mode 100644 index 0000000..d7d2c30 --- /dev/null +++ b/app/src/main/java/no/svorka/mcms/ui/DashboardScreen.kt @@ -0,0 +1,232 @@ +package no.svorka.mcms.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import no.svorka.mcms.core.* + +class DashboardModel(private val connection: McmsConnection) { + var phase by mutableStateOf(LoadPhase.Idle) + var onuTotal by mutableStateOf(0) + var oltCount by mutableStateOf(0) + var controllerCount by mutableStateOf(0) + var onuCounts by mutableStateOf>>(emptyList()) + var severityCounts by mutableStateOf>>(emptyList()) + var totalDownBps by mutableStateOf(0.0) + var totalUpBps by mutableStateOf(0.0) + var abnormalRx by mutableStateOf>(emptyList()) + var lasersOff by mutableStateOf>(emptyList()) + var opticalAvailable by mutableStateOf(false) + + suspend fun load(extraStats: Boolean) { + phase = LoadPhase.Loading + try { + val olts = connection.olt.allStates() + oltCount = olts.size + val reg = OltRepository.registrationMap(olts) + onuTotal = reg.size + onuCounts = reg.values.groupingBy { it }.eachCount().toList().sortedByDescending { it.second } + totalDownBps = olts.mapNotNull { it.txRateBps }.sum() + totalUpBps = olts.mapNotNull { it.rxRateBps }.sum() + lasersOff = olts.filter { it.isLaserOff } + controllerCount = runCatching { connection.controller.allConfigs().size }.getOrDefault(0) + val alarms = runCatching { connection.onu.alarmHistories() }.getOrDefault(emptyList()) + severityCounts = alarms.groupingBy { it.severity }.eachCount().toList().sortedBy { it.first.rank } + if (extraStats) { + val onus = connection.onu.allStates() + opticalAvailable = onus.any { it.rxOpticalDBm != null } + abnormalRx = onus.filter { val rx = it.rxOpticalDBm; rx != null && (rx < -28 || rx > -10) } + } else { + opticalAvailable = false; abnormalRx = emptyList() + } + phase = LoadPhase.Loaded + } catch (e: Throwable) { + phase = LoadPhase.Failed(e.uiMessage()) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DashboardScreen( + connection: McmsConnection, + openAbnormalRx: (List) -> Unit, + openLaserOff: (List) -> Unit, +) { + val model = remember(connection) { DashboardModel(connection) } + var extraStats by rememberSaveable { mutableStateOf(false) } + var autoRefresh by rememberSaveable { mutableStateOf(false) } + var menuOpen by remember { mutableStateOf(false) } + + LaunchedEffect(extraStats, autoRefresh) { + while (true) { + model.load(extraStats) + if (!autoRefresh) break + delay(30_000) + } + } + + Scaffold(topBar = { + TopAppBar( + title = { Text("Dashboard") }, + actions = { + IconButton(onClick = { menuOpen = true }) { Icon(Icons.Filled.MoreVert, "Options") } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + DropdownMenuItem( + text = { Text("Detailed stats (Rx scan)") }, + trailingIcon = { Switch(checked = extraStats, onCheckedChange = { extraStats = it }) }, + onClick = { extraStats = !extraStats }, + ) + DropdownMenuItem( + text = { Text("Auto-refresh · 30s") }, + trailingIcon = { Switch(checked = autoRefresh, onCheckedChange = { autoRefresh = it }) }, + onClick = { autoRefresh = !autoRefresh }, + ) + } + }, + ) + }) { padding -> + Box(Modifier.padding(padding).fillMaxSize()) { + when (val p = model.phase) { + is LoadPhase.Idle, is LoadPhase.Loading -> if (model.onuTotal == 0 && model.oltCount == 0) LoadingView("Loading network…") else content(model, openAbnormalRx, openLaserOff) + is LoadPhase.Failed -> ErrorState(p.message) + is LoadPhase.Loaded -> content(model, openAbnormalRx, openLaserOff) + } + } + } +} + +@Composable +private fun content( + model: DashboardModel, + openAbnormalRx: (List) -> Unit, + openLaserOff: (List) -> Unit, +) { + LazyColumn( + Modifier.fillMaxSize().padding(16.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + item { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + CountTile("ONUs", model.onuTotal, Modifier.weight(1f)) + CountTile("OLTs", model.oltCount, Modifier.weight(1f)) + CountTile("Controllers", model.controllerCount, Modifier.weight(1f)) + } + } + if (model.totalDownBps > 0 || model.totalUpBps > 0) { + item { + SectionCard("Traffic · all OLTs") { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Column { + Text("↓ Downstream", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(Format.bps(model.totalDownBps), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + } + Column(horizontalAlignment = Alignment.End) { + Text("↑ Upstream", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(Format.bps(model.totalUpBps), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + } + } + } + } + } + if (model.opticalAvailable || model.lasersOff.isNotEmpty()) { + item { + SectionCard("Health") { + if (model.opticalAvailable) { + HealthRow("ONUs with abnormal Rx", "< −28 or > −10 dBm", model.abnormalRx.size) { + if (model.abnormalRx.isNotEmpty()) openAbnormalRx(model.abnormalRx) + } + } + HealthRow("OLTs with laser off", null, model.lasersOff.size) { + if (model.lasersOff.isNotEmpty()) openLaserOff(model.lasersOff) + } + } + } + } + if (model.onuCounts.isNotEmpty()) { + item { + SectionCard("ONU states") { + model.onuCounts.forEach { (state, count) -> + Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) { + StateBadge(state.label, state.color()) + Spacer(Modifier.weight(1f)) + Text("$count") + } + } + } + } + } + } +} + +@Composable +private fun HealthRow(title: String, detail: String?, count: Int, onClick: () -> Unit) { + Row( + Modifier.fillMaxWidth().clickable(enabled = count > 0, onClick = onClick).padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text(title) + if (detail != null) Text(detail, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Text("$count", fontWeight = FontWeight.SemiBold, color = if (count > 0) Color(0xFFEF6C00) else Color(0xFF2E7D32)) + if (count > 0) Icon(Icons.Filled.ChevronRight, null, tint = MaterialTheme.colorScheme.outline) + } +} + +// ── Drill-down screens ── + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AbnormalRxScreen(onus: List, onBack: () -> Unit, openOnu: (String) -> Unit) { + Scaffold(topBar = { TopAppBar(title = { Text("Abnormal Rx") }, navigationIcon = { BackButton(onBack) }) }) { padding -> + LazyColumn(Modifier.padding(padding).fillMaxSize()) { + items(onus) { onu -> + ListRow(onClick = { openOnu(onu.id) }) { + Column(Modifier.weight(1f)) { + Text(onu.displayName, fontWeight = FontWeight.Medium) + onu.parentOlt?.let { Text("OLT $it", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } + } + onu.rxOpticalDBm?.let { rx -> + Column(horizontalAlignment = Alignment.End) { + Text("${"%.1f".format(rx)} dBm", color = Color(0xFFEF6C00)) + Text(if (rx < -28) "weak" else "strong", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + HorizontalDivider() + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LaserOffScreen(olts: List, onBack: () -> Unit, openOlt: (String, String) -> Unit) { + Scaffold(topBar = { TopAppBar(title = { Text("Laser off") }, navigationIcon = { BackButton(onBack) }) }) { padding -> + LazyColumn(Modifier.padding(padding).fillMaxSize()) { + items(olts) { olt -> + ListRow(onClick = { openOlt(olt.id, olt.displayName) }) { + Column(Modifier.weight(1f)) { + Text(olt.displayName, fontWeight = FontWeight.Medium) + Text(olt.laserShutdown ?: "Laser off", style = MaterialTheme.typography.bodySmall, color = Color(0xFFEF6C00)) + } + } + HorizontalDivider() + } + } + } +} diff --git a/app/src/main/java/no/svorka/mcms/ui/OltScreens.kt b/app/src/main/java/no/svorka/mcms/ui/OltScreens.kt new file mode 100644 index 0000000..5444240 --- /dev/null +++ b/app/src/main/java/no/svorka/mcms/ui/OltScreens.kt @@ -0,0 +1,202 @@ +package no.svorka.mcms.ui + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import no.svorka.mcms.core.* + +class OltListModel(private val connection: McmsConnection) { + var phase by mutableStateOf(LoadPhase.Idle) + var olts by mutableStateOf>(emptyList()) + var search by mutableStateOf("") + + val filtered: List + get() { + val q = search.trim() + if (q.isEmpty()) return olts + return olts.filter { it.id.contains(q, true) || (it.resolvedName?.contains(q, true) == true) } + } + + suspend fun load() { + phase = LoadPhase.Loading + try { + val fetched = connection.olt.allStates() + val names = runCatching { connection.olt.nameMap() }.getOrDefault(emptyMap()) + fetched.forEach { it.resolvedName = names[it.id] } + olts = fetched.sortedBy { it.displayName.lowercase() } + phase = LoadPhase.Loaded + } catch (e: Throwable) { + phase = LoadPhase.Failed(e.uiMessage()) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OltListScreen(connection: McmsConnection, openOlt: (String, String) -> Unit) { + val model = remember(connection) { OltListModel(connection) } + LaunchedEffect(connection) { if (model.phase is LoadPhase.Idle) model.load() } + + Scaffold(topBar = { TopAppBar(title = { Text("OLTs") }) }) { padding -> + Column(Modifier.padding(padding).fillMaxSize()) { + OutlinedTextField( + value = model.search, onValueChange = { model.search = it }, + placeholder = { Text("OLT name or MAC") }, singleLine = true, + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), + ) + when (val p = model.phase) { + is LoadPhase.Idle, is LoadPhase.Loading -> if (model.olts.isEmpty()) LoadingView("Loading OLTs…") else OltList(model.filtered, openOlt) + is LoadPhase.Failed -> ErrorState(p.message) + is LoadPhase.Loaded -> OltList(model.filtered, openOlt) + } + } + } +} + +@Composable +private fun OltList(olts: List, openOlt: (String, String) -> Unit) { + LazyColumn(Modifier.fillMaxSize()) { + items(olts) { olt -> + ListRow(onClick = { openOlt(olt.id, olt.displayName) }) { + Column(Modifier.weight(1f)) { + Text(olt.displayName, fontWeight = FontWeight.Medium) + Text("${olt.totalOnus} ONUs · ${olt.onlineOnus} online", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + val down = olt.totalOnus - olt.onlineOnus + when { + olt.totalOnus == 0 -> StateBadge("no ONUs", Color(0xFF9E9E9E)) + down > 0 -> StateBadge("$down down", Color(0xFFEF6C00)) + else -> StateBadge("all up", Color(0xFF2E7D32)) + } + } + HorizontalDivider() + } + } +} + +// ───────────────────────── Detail ───────────────────────── + +class OltDetailModel(private val connection: McmsConnection, val oltId: String) { + var phase by mutableStateOf(LoadPhase.Idle) + var olt by mutableStateOf(null) + var actionMessage by mutableStateOf(null) + var busy by mutableStateOf(false) + + suspend fun load() { + phase = LoadPhase.Loading + try { olt = connection.olt.state(oltId); phase = LoadPhase.Loaded } + catch (e: Throwable) { phase = LoadPhase.Failed(e.uiMessage()) } + } + + suspend fun reset() { + busy = true; actionMessage = null + try { connection.olt.reset(oltId); actionMessage = "Reset requested." } catch (e: Throwable) { actionMessage = e.uiMessage() } + busy = false + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OltDetailScreen(connection: McmsConnection, oltId: String, title: String, onBack: () -> Unit, openOnu: (String) -> Unit) { + val model = remember(oltId) { OltDetailModel(connection, oltId) } + val scope = rememberCoroutineScope() + var showReset by remember { mutableStateOf(false) } + var menu by remember { mutableStateOf(false) } + LaunchedEffect(oltId) { model.load() } + + Scaffold(topBar = { + TopAppBar( + title = { Text(title) }, + navigationIcon = { BackButton(onBack) }, + actions = { + IconButton(onClick = { menu = true }) { Icon(Icons.Filled.MoreVert, "Actions") } + DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) { + DropdownMenuItem(text = { Text("Reset OLT", color = MaterialTheme.colorScheme.error) }, + enabled = !model.busy, onClick = { menu = false; showReset = true }) + } + }, + ) + }) { padding -> + Box(Modifier.padding(padding).fillMaxSize()) { + when (val p = model.phase) { + is LoadPhase.Idle, is LoadPhase.Loading -> if (model.olt == null) LoadingView() else OltContent(model.olt!!, openOnu) + is LoadPhase.Failed -> ErrorState(p.message) { scope.launch { model.load() } } + is LoadPhase.Loaded -> model.olt?.let { OltContent(it, openOnu) } + } + } + } + + model.actionMessage?.let { msg -> + AlertDialog(onDismissRequest = { model.actionMessage = null }, + confirmButton = { TextButton(onClick = { model.actionMessage = null }) { Text("OK") } }, + title = { Text("Operation") }, text = { Text(msg) }) + } + if (showReset) ResetConfirmDialog("OLT", title, onDismiss = { showReset = false }) { scope.launch { model.reset() } } +} + +@Composable +private fun OltContent(olt: OltStateDoc, openOnu: (String) -> Unit) { + LazyColumn(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + item { + SectionCard("Status") { + val down = olt.totalOnus - olt.onlineOnus + KeyValueRow("ONUs", "${olt.totalOnus} · ${olt.onlineOnus} online" + if (down > 0) " · $down down" else "") + olt.model?.let { KeyValueRow("Model", it) } + olt.ponMode?.let { KeyValueRow("PON mode", it) } + olt.firmwareVersion?.let { KeyValueRow("Firmware", it) } + olt.onlineTime?.let { + MongoTimestamp.duration(it, olt.lastUpdate)?.let { up -> KeyValueRow("Uptime", up) } + KeyValueRow("Online since", MongoTimestamp.displayString(it) ?: it) + } + } + } + if (olt.rxRateBps != null || olt.txRateBps != null) { + item { + SectionCard("PON traffic") { + KeyValueRow("Downstream", "${Format.bps(olt.txRateBps)} · ${Format.percent(olt.txUtilPercent)} util") + KeyValueRow("Upstream", "${Format.bps(olt.rxRateBps)} · ${Format.percent(olt.rxUtilPercent)} util") + } + } + } + if (olt.asicTempC != null || olt.voltage != null) { + item { + SectionCard("Environment") { + olt.asicTempC?.let { KeyValueRow("ASIC temp", "$it °C") } + olt.voltage?.let { KeyValueRow("Voltage", "%.2f V".format(it)) } + } + } + } + olt.switchName?.let { sw -> + item { + SectionCard("Uplink") { + KeyValueRow("Switch", sw) + olt.switchPort?.let { KeyValueRow("Port", it) } + olt.switchAddress?.let { KeyValueRow("Address", it) } + } + } + } + olt.populatedBuckets.forEach { (state, ids) -> + item { + Row(verticalAlignment = Alignment.CenterVertically) { + StateBadge(state.label, state.color()) + Spacer(Modifier.weight(1f)) + Text("${ids.size}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + items(ids) { onuId -> + ListRow(onClick = { openOnu(onuId) }) { Text(onuId) } + HorizontalDivider() + } + } + } +} diff --git a/app/src/main/java/no/svorka/mcms/ui/OnuScreens.kt b/app/src/main/java/no/svorka/mcms/ui/OnuScreens.kt new file mode 100644 index 0000000..1af94b2 --- /dev/null +++ b/app/src/main/java/no/svorka/mcms/ui/OnuScreens.kt @@ -0,0 +1,453 @@ +package no.svorka.mcms.ui + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import no.svorka.mcms.core.* + +// ───────────────────────── List ───────────────────────── + +class OnuListModel(private val connection: McmsConnection) { + var phase by mutableStateOf(LoadPhase.Idle) + var onus by mutableStateOf>(emptyList()) + var search by mutableStateOf("") + + val filtered: List + get() { + val q = search.trim() + if (q.isEmpty()) return onus + return onus.filter { + it.id.contains(q, true) || + (it.resolvedName?.contains(q, true) == true) || + (it.parentOlt?.contains(q, true) == true) + } + } + + suspend fun load() { + phase = LoadPhase.Loading + try { + val fetched = connection.onu.allStates() + val reg = runCatching { connection.olt.registrationMap() }.getOrDefault(emptyMap()) + val names = runCatching { connection.onu.nameMap() }.getOrDefault(emptyMap()) + fetched.forEach { it.resolvedLifecycle = reg[it.id]; it.resolvedName = names[it.id] } + onus = fetched.sortedBy { it.displayName.lowercase() } + phase = LoadPhase.Loaded + } catch (e: Throwable) { + phase = LoadPhase.Failed(e.uiMessage()) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OnuListScreen(connection: McmsConnection, openOnu: (String) -> Unit) { + val model = remember(connection) { OnuListModel(connection) } + LaunchedEffect(connection) { if (model.phase is LoadPhase.Idle) model.load() } + + Scaffold(topBar = { TopAppBar(title = { Text("ONUs") }) }) { padding -> + Column(Modifier.padding(padding).fillMaxSize()) { + OutlinedTextField( + value = model.search, onValueChange = { model.search = it }, + placeholder = { Text("Name, serial, or OLT") }, singleLine = true, + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), + ) + when (val p = model.phase) { + is LoadPhase.Idle, is LoadPhase.Loading -> if (model.onus.isEmpty()) LoadingView("Loading ONUs…") else OnuList(model.filtered, openOnu) + is LoadPhase.Failed -> ErrorState(p.message) { /* retry */ } + is LoadPhase.Loaded -> OnuList(model.filtered, openOnu) + } + } + } +} + +@Composable +private fun OnuList(onus: List, openOnu: (String) -> Unit) { + LazyColumn(Modifier.fillMaxSize()) { + items(onus) { onu -> + ListRow(onClick = { openOnu(onu.id) }) { + Column(Modifier.weight(1f)) { + Text(onu.displayName, fontWeight = FontWeight.Medium) + subtitle(onu)?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } + } + StateBadge(onu.lifecycle.label, onu.lifecycle.color()) + } + HorizontalDivider() + } + } +} + +private fun subtitle(onu: OnuStateDoc): String? { + val parts = buildList { + if (onu.resolvedName != null) add(onu.id) + onu.parentOlt?.let { add("OLT $it") } + } + return parts.joinToString(" · ").ifEmpty { null } +} + +// ───────────────────────── Detail ───────────────────────── + +private val prettyJson = Json { prettyPrint = true } + +class OnuDetailModel(private val connection: McmsConnection, val onuId: String) { + var tab by mutableStateOf(0) // 0 overview · 1 cpe · 2 logs · 3 config + var phase by mutableStateOf(LoadPhase.Idle) + var liveState by mutableStateOf(null) + var cpes by mutableStateOf>(emptyList()) + var logs by mutableStateOf>(emptyList()) + var config by mutableStateOf(null) + var firmwareFiles by mutableStateOf>(emptyList()) + var firmwareError by mutableStateOf(null) + var actionMessage by mutableStateOf(null) + var busy by mutableStateOf(false) + + suspend fun loadTab() { phase = LoadPhase.Loading; reload() } + suspend fun refresh() { reload() } + private suspend fun reload() { + try { + when (tab) { + 0 -> liveState = connection.onu.state(onuId) + 1 -> cpes = connection.onu.cpes(onuId) + 2 -> logs = connection.onu.logs(onuId) + 3 -> config = connection.onu.config(onuId) + } + phase = LoadPhase.Loaded + } catch (e: Throwable) { + phase = LoadPhase.Failed(e.uiMessage()) + } + } + + suspend fun loadFirmware() { + firmwareError = null + try { firmwareFiles = connection.onu.firmwareFiles() } catch (e: Throwable) { firmwareError = e.uiMessage() } + } + + suspend fun reset() { + busy = true; actionMessage = null + try { connection.onu.reset(onuId); actionMessage = "Reset requested." } catch (e: Throwable) { actionMessage = e.uiMessage() } + busy = false + } + + suspend fun upgrade(file: FirmwareFile) { + busy = true; actionMessage = null + try { + val slot = connection.onu.upgradeFirmware(onuId, file) + actionMessage = "Firmware ${file.version ?: "image"} staged to bank $slot. The ONU starts it after its next reboot." + } catch (e: Throwable) { actionMessage = e.uiMessage() } + busy = false + } +} + +private val onuTabs = listOf("Overview", "CPE", "Logs", "Config") + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OnuDetailScreen(connection: McmsConnection, onuId: String, onBack: () -> Unit) { + val model = remember(onuId) { OnuDetailModel(connection, onuId) } + val scope = rememberCoroutineScope() + var showReset by remember { mutableStateOf(false) } + var showFirmware by remember { mutableStateOf(false) } + var menu by remember { mutableStateOf(false) } + + LaunchedEffect(model.tab) { model.loadTab() } + + Scaffold(topBar = { + TopAppBar( + title = { Text(onuId) }, + navigationIcon = { BackButton(onBack) }, + actions = { + IconButton(onClick = { menu = true }) { Icon(Icons.Filled.MoreVert, "Actions") } + DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) { + DropdownMenuItem(text = { Text("Reset ONU", color = MaterialTheme.colorScheme.error) }, + enabled = !model.busy, onClick = { menu = false; showReset = true }) + } + }, + ) + }) { padding -> + Column(Modifier.padding(padding).fillMaxSize()) { + PrimaryTabRow(selectedTabIndex = model.tab) { + onuTabs.forEachIndexed { i, title -> + Tab(selected = model.tab == i, onClick = { model.tab = i }, text = { Text(title) }) + } + } + Box(Modifier.fillMaxSize()) { + when (val p = model.phase) { + is LoadPhase.Loading, is LoadPhase.Idle -> LoadingView() + is LoadPhase.Failed -> ErrorState(p.message) { scope.launch { model.loadTab() } } + is LoadPhase.Loaded -> when (model.tab) { + 0 -> OverviewTab(model) { showFirmware = true } + 1 -> CpeTab(model.cpes) + 2 -> LogsTab(model.logs) + else -> ConfigTab(model.config) + } + } + } + } + } + + model.actionMessage?.let { msg -> + AlertDialog( + onDismissRequest = { model.actionMessage = null }, + confirmButton = { TextButton(onClick = { model.actionMessage = null }) { Text("OK") } }, + title = { Text("Operation") }, text = { Text(msg) }, + ) + } + if (showReset) ResetConfirmDialog("ONU", onuId, onDismiss = { showReset = false }) { scope.launch { model.reset() } } + if (showFirmware) FirmwareDialog(model, onDismiss = { showFirmware = false }) +} + +@Composable +private fun OverviewTab(model: OnuDetailModel, onUpdateFirmware: () -> Unit) { + val s = model.liveState ?: return + LazyColumn(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + item { + SectionCard("Status") { + s.registrationState?.let { KeyValueRow("Registration", it) } + s.equipmentId?.let { KeyValueRow("Equipment", it) } + s.ponMode?.let { KeyValueRow("PON mode", it) } + s.temperatureC?.let { KeyValueRow("Temperature", "%.1f °C".format(it)) } + s.onlineTime?.let { + KeyValueRow("Online since", MongoTimestamp.displayString(it) ?: it) + MongoTimestamp.duration(it, s.lastUpdate)?.let { up -> KeyValueRow("Uptime", up) } + } + } + } + item { + SectionCard("Firmware") { + s.fwVersion?.let { KeyValueRow("Active version", it) } + s.fwBankVersions.forEachIndexed { i, v -> + KeyValueRow("Bank $i" + if (s.fwBankPtr == i) " · active" else "", v.ifEmpty { "—" }) + } + Spacer(Modifier.height(8.dp)) + Button(onClick = onUpdateFirmware, enabled = !model.busy) { Text("Update firmware…") } + } + } + item { + SectionCard("Live optical") { + OpticalRow("RX optical", s.rxOpticalDBm, green = -28.0, warn = -30.0) + OpticalRow("TX optical", s.txOpticalDBm, green = 3.0, warn = 3.0) + FecRow("ONU post-FEC", s.onuPostFec, isPost = true) + FecRow("OLT post-FEC", s.oltPostFec, isPost = true) + } + } + if (s.uniPorts.isNotEmpty()) { + item { + SectionCard("UNI ports") { + s.uniPorts.forEach { port -> + Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(port.name) + (port.state ?: listOfNotNull(port.speed, port.duplex).joinToString(" · ").ifEmpty { null }) + ?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } + } + port.enabled?.let { StateBadge(if (it) "enabled" else "disabled", if (it) Color(0xFF2E7D32) else Color(0xFF757575)) } + } + } + } + } + } + if (s.ponServices.isNotEmpty()) { + item { + SectionCard("Traffic") { + s.ponServices.forEach { svc -> + Text(svc.name, fontWeight = FontWeight.Medium) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text("↓ ${Format.bps(svc.rxRateBps)}") + Text("↑ ${Format.bps(svc.txRateBps)}") + } + Spacer(Modifier.height(6.dp)) + } + } + } + } + } +} + +@Composable +private fun OpticalRow(title: String, value: Double?, green: Double, warn: Double) { + Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), horizontalArrangement = Arrangement.SpaceBetween) { + Text(title) + val color = when { + value == null -> MaterialTheme.colorScheme.onSurfaceVariant + value >= green -> Color(0xFF2E7D32) + value >= warn -> Color(0xFFF9A825) + else -> Color(0xFFC62828) + } + Text(value?.let { "%.2f dBm".format(it) } ?: "—", color = color) + } +} + +@Composable +private fun FecRow(title: String, value: Double?, isPost: Boolean) { + Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), horizontalArrangement = Arrangement.SpaceBetween) { + Text(title) + val color = when { + value == null -> MaterialTheme.colorScheme.onSurfaceVariant + isPost && value > 0 -> Color(0xFFC62828) + value > 0 -> MaterialTheme.colorScheme.onSurfaceVariant + else -> Color(0xFF2E7D32) + } + Text(value?.let { it.toLong().toString() } ?: "—", color = color) + } +} + +@Composable +private fun CpeTab(cpes: List) { + if (cpes.isEmpty()) { EmptyState("No CPEs"); return } + LazyColumn(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { + items(cpes) { cpe -> + SectionCard(cpe.mac ?: cpe.id) { + cpe.ipv4?.let { + KeyValueRow("IPv4", it + (cpe.dhcpState?.let { s -> " ($s)" } ?: "")) + cpe.dhcpServer?.let { s -> KeyValueRow("DHCP server", s) } + cpe.dhcpLeaseSeconds?.let { l -> KeyValueRow("Lease", "${l / 3600}h") } + } + cpe.ipv6?.let { + KeyValueRow("IPv6", it) + cpe.ipv6Prefix?.let { p -> KeyValueRow("Delegated prefix", p) } + } + cpe.circuitId?.let { KeyValueRow("Circuit ID", it) } + if (!cpe.hasLease) Text("No active lease.", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } +} + +@Composable +private fun LogsTab(logs: List) { + if (logs.isEmpty()) { EmptyState("No logs"); return } + LazyColumn(Modifier.fillMaxSize()) { + items(logs) { log -> + Column(Modifier.fillMaxWidth().padding(16.dp, 8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + StateBadge(log.severity.label, log.severity.color()) + Spacer(Modifier.weight(1f)) + MongoTimestamp.displayString(log.time)?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } + } + Text(log.message ?: log.id, style = MaterialTheme.typography.bodySmall) + } + HorizontalDivider() + } + } +} + +@Composable +private fun ConfigTab(config: OnuConfigDoc?) { + if (config == null) { EmptyState("No configuration"); return } + Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) { + config.name?.let { KeyValueRow("Name", it); Spacer(Modifier.height(8.dp)) } + SelectionContainer { + Text(prettyJson.encodeToString(JsonElement.serializer(), config.raw), + fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall) + } + } +} + +@Composable +private fun EmptyState(text: String) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text(text, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +// ───────────────────────── Reset + Firmware dialogs ───────────────────────── + +@Composable +fun ResetConfirmDialog(kind: String, name: String, onDismiss: () -> Unit, onConfirm: () -> Unit) { + var ack by remember { mutableStateOf(false) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Reset $kind?") }, + text = { + Column { + Text("Resetting $kind “$name” reboots it and interrupts service for everything connected to it. This can't be undone.") + Spacer(Modifier.height(12.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = ack, onCheckedChange = { ack = it }) + Text("I understand this will interrupt service") + } + } + }, + confirmButton = { + TextButton(enabled = ack, onClick = { onConfirm(); onDismiss() }) { + Text("Reset $kind", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun FirmwareDialog(model: OnuDetailModel, onDismiss: () -> Unit) { + val scope = rememberCoroutineScope() + var showAll by remember { mutableStateOf(false) } + var pending by remember { mutableStateOf(null) } + val state = model.liveState + LaunchedEffect(Unit) { if (model.firmwareFiles.isEmpty()) model.loadFirmware() } + + val compatible = model.firmwareFiles.filter { it.isCompatible(state?.vendor, state?.equipmentId) } + val visible = if (showAll) model.firmwareFiles else compatible + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Update firmware") }, + text = { + Column { + state?.fwVersion?.let { Text("Current: $it", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } + Spacer(Modifier.height(8.dp)) + when { + model.firmwareError != null -> Text(model.firmwareError!!, color = MaterialTheme.colorScheme.error) + model.firmwareFiles.isEmpty() -> Row(verticalAlignment = Alignment.CenterVertically) { CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp); Spacer(Modifier.width(8.dp)); Text("Loading…") } + else -> Column { + visible.forEach { file -> + ListRow(onClick = { pending = file }) { + Column(Modifier.weight(1f)) { + Text(file.version ?: file.filename ?: "Unknown", fontWeight = FontWeight.Medium) + Text(listOfNotNull(file.models.firstOrNull(), file.sizeBytes?.let { Format.bytes(it) }).joinToString(" · "), + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + if (model.firmwareFiles.size > compatible.size) { + Row(verticalAlignment = Alignment.CenterVertically) { + Switch(checked = showAll, onCheckedChange = { showAll = it }); Spacer(Modifier.width(8.dp)); Text("Show all firmware") + } + } + } + } + } + }, + confirmButton = {}, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) + + pending?.let { file -> + AlertDialog( + onDismissRequest = { pending = null }, + 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.") }, + confirmButton = { + TextButton(onClick = { pending = null; onDismiss(); scope.launch { model.upgrade(file) } }) { + Text("Download ${file.version ?: "firmware"}", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { TextButton(onClick = { pending = null }) { Text("Cancel") } }, + ) + } +} diff --git a/app/src/main/java/no/svorka/mcms/ui/Theme.kt b/app/src/main/java/no/svorka/mcms/ui/Theme.kt new file mode 100644 index 0000000..5974d14 --- /dev/null +++ b/app/src/main/java/no/svorka/mcms/ui/Theme.kt @@ -0,0 +1,31 @@ +package no.svorka.mcms.ui + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +private val Orange = Color(0xFFF39200) +private val Navy = Color(0xFF0B2A4A) + +private val LightColors = lightColorScheme( + primary = Orange, + onPrimary = Color.White, + secondary = Navy, +) + +private val DarkColors = darkColorScheme( + primary = Orange, + onPrimary = Color.Black, + secondary = Color(0xFF9FB8D6), +) + +@Composable +fun PonGoTheme(content: @Composable () -> Unit) { + MaterialTheme( + colorScheme = if (isSystemInDarkTheme()) DarkColors else LightColors, + content = content, + ) +} diff --git a/app/src/main/java/no/svorka/mcms/ui/Types.kt b/app/src/main/java/no/svorka/mcms/ui/Types.kt new file mode 100644 index 0000000..1f1cd80 --- /dev/null +++ b/app/src/main/java/no/svorka/mcms/ui/Types.kt @@ -0,0 +1,35 @@ +package no.svorka.mcms.ui + +import androidx.compose.ui.graphics.Color +import no.svorka.mcms.core.AlarmSeverity +import no.svorka.mcms.core.OnuLifecycleState + +/** Screen load state. */ +sealed interface LoadPhase { + data object Idle : LoadPhase + data object Loading : LoadPhase + data object Loaded : LoadPhase + data class Failed(val message: String) : LoadPhase +} + +fun OnuLifecycleState.color(): Color = when (this) { + OnuLifecycleState.REGISTERED -> Color(0xFF2E7D32) + OnuLifecycleState.UNSPECIFIED -> Color(0xFF26A69A) + OnuLifecycleState.UNPROVISIONED -> Color(0xFF1565C0) + OnuLifecycleState.DEREGISTERED -> Color(0xFF757575) + OnuLifecycleState.DYING_GASP, OnuLifecycleState.DISALLOWED_ERROR -> Color(0xFFC62828) + OnuLifecycleState.DISABLED, OnuLifecycleState.DISALLOWED_ADMIN, OnuLifecycleState.DISALLOWED_REG_ID -> Color(0xFFEF6C00) + OnuLifecycleState.UNKNOWN -> Color(0xFF9E9E9E) +} + +fun AlarmSeverity.color(): Color = when (this) { + AlarmSeverity.EMERGENCY, AlarmSeverity.ALERT, AlarmSeverity.CRITICAL -> Color(0xFFC62828) + AlarmSeverity.ERROR -> Color(0xFFEF6C00) + AlarmSeverity.WARNING -> Color(0xFFF9A825) + AlarmSeverity.NOTICE, AlarmSeverity.INFO -> Color(0xFF1565C0) + AlarmSeverity.DEBUG -> Color(0xFF757575) + AlarmSeverity.UNKNOWN -> Color(0xFF9E9E9E) +} + +/** Maps a thrown error to a user message. */ +fun Throwable.uiMessage(): String = message ?: "Something went wrong" diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..be43858 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..be43858 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..29a9260 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..d8b34b1 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..29a9260 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..0f7332d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..14fda66 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..0f7332d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..2135dde Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..b48620f Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..2135dde Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..c1aea7b Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..2cb1f61 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..c1aea7b Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..dfa1a3f Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..6f89534 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..dfa1a3f Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..53ad93f --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #002049 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..b326b67 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + PON Go + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..7eb3e3c --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +