diff --git a/.claude/settings.json b/.claude/settings.json index ffdc54d77..5194d87dd 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -6,12 +6,12 @@ "hooks": [ { "type": "command", - "command": "python3 .claude/hooks/pr_gate.py", + "command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/pr_gate.py\"", "timeout": 10 }, { "type": "command", - "command": "staged=$(git diff --cached --name-only --diff-filter=ACM -- '*.dart'); if [ -n \"$staged\" ]; then if command -v fvm >/dev/null 2>&1; then echo \"$staged\" | xargs fvm dart format; else echo \"$staged\" | xargs dart format; fi; echo \"$staged\" | xargs git add; fi", + "command": "cd \"$CLAUDE_PROJECT_DIR\" || exit 0; staged=$(git diff --cached --name-only --diff-filter=ACM -- '*.dart'); if [ -n \"$staged\" ]; then if command -v fvm >/dev/null 2>&1; then echo \"$staged\" | xargs fvm dart format; else echo \"$staged\" | xargs dart format; fi; echo \"$staged\" | xargs git add; fi", "timeout": 30, "statusMessage": "Formatting staged Dart files..." } diff --git a/web/assets/notosans/v37/o-0mIpQlx3QUlC5A4PNB6Ryti20_6n1iPHjcz6L1SoM-jCpoiyD9A99Y41P6zHtY.woff2 b/assets/fonts/fallback/NotoSans-Latin.woff2 similarity index 100% rename from web/assets/notosans/v37/o-0mIpQlx3QUlC5A4PNB6Ryti20_6n1iPHjcz6L1SoM-jCpoiyD9A99Y41P6zHtY.woff2 rename to assets/fonts/fallback/NotoSans-Latin.woff2 diff --git a/web/assets/notosansarabic/v28/nwpxtLGrOAZMl5nJ_wfgRg3DrWFZWsnVBJ_sS6tlqHHFlhQ5l3sQWIHPqzCfyGyvvnCBFQLaig.woff2 b/assets/fonts/fallback/NotoSansArabic.woff2 similarity index 100% rename from web/assets/notosansarabic/v28/nwpxtLGrOAZMl5nJ_wfgRg3DrWFZWsnVBJ_sS6tlqHHFlhQ5l3sQWIHPqzCfyGyvvnCBFQLaig.woff2 rename to assets/fonts/fallback/NotoSansArabic.woff2 diff --git a/assets/fonts/fallback/NotoSansCJKhk.subset.woff2 b/assets/fonts/fallback/NotoSansCJKhk.subset.woff2 new file mode 100644 index 000000000..f50233fa8 Binary files /dev/null and b/assets/fonts/fallback/NotoSansCJKhk.subset.woff2 differ diff --git a/assets/fonts/fallback/NotoSansCJKjp.subset.woff2 b/assets/fonts/fallback/NotoSansCJKjp.subset.woff2 new file mode 100644 index 000000000..08d6dc0eb Binary files /dev/null and b/assets/fonts/fallback/NotoSansCJKjp.subset.woff2 differ diff --git a/assets/fonts/fallback/NotoSansCJKkr.subset.woff2 b/assets/fonts/fallback/NotoSansCJKkr.subset.woff2 new file mode 100644 index 000000000..803debfbf Binary files /dev/null and b/assets/fonts/fallback/NotoSansCJKkr.subset.woff2 differ diff --git a/assets/fonts/fallback/NotoSansCJKsc.subset.woff2 b/assets/fonts/fallback/NotoSansCJKsc.subset.woff2 new file mode 100644 index 000000000..a90582428 Binary files /dev/null and b/assets/fonts/fallback/NotoSansCJKsc.subset.woff2 differ diff --git a/assets/fonts/fallback/NotoSansCJKtc.subset.woff2 b/assets/fonts/fallback/NotoSansCJKtc.subset.woff2 new file mode 100644 index 000000000..a68c24af0 Binary files /dev/null and b/assets/fonts/fallback/NotoSansCJKtc.subset.woff2 differ diff --git a/web/assets/notosansthai/v25/iJWnBXeUZi_OHPqn4wq6hQ2_hbJ1xyN9wd43SofNWcd1MKVQt_So_9CdU5RtpzR-QRvzzXg.woff2 b/assets/fonts/fallback/NotoSansThai.woff2 similarity index 100% rename from web/assets/notosansthai/v25/iJWnBXeUZi_OHPqn4wq6hQ2_hbJ1xyN9wd43SofNWcd1MKVQt_So_9CdU5RtpzR-QRvzzXg.woff2 rename to assets/fonts/fallback/NotoSansThai.woff2 diff --git a/web/assets/roboto/v32/KFOmCnqEu92Fr1Me4GZLCzYlKw.woff2 b/assets/fonts/fallback/Roboto.woff2 similarity index 100% rename from web/assets/roboto/v32/KFOmCnqEu92Fr1Me4GZLCzYlKw.woff2 rename to assets/fonts/fallback/Roboto.woff2 diff --git a/constitution.md b/constitution.md index 10299bc43..4c9d7feae 100644 --- a/constitution.md +++ b/constitution.md @@ -4,7 +4,7 @@ **Status:** Active **Context:** Source of Truth for Architectural Discipline **Ratified:** 2025-12-09 -**Last Amended:** 2026-03-20 +**Last Amended:** 2026-06-30 ## Preamble This document establishes the immutable principles governing the development process of the Linksys Flutter application. It serves as the architectural DNA of the system, ensuring consistency, simplicity, and quality across all implementations. @@ -233,12 +233,11 @@ class DeviceInfo { ... } // generated from Device.DeviceInfo. **3.3.5: Error Classes** ```dart -// Naming pattern: [Type]Error (final class extending sealed base) -sealed class AuthError { ... } - -final class InvalidCredentialsError extends AuthError { ... } -final class NetworkError extends AuthError { ... } -final class StorageError extends AuthError { ... } +// Naming pattern: [Type]Error (final class extending the sealed ServiceError) +// See Article XIII for the unified error hierarchy. +final class InvalidCredentialsError extends ServiceError { ... } +final class NetworkError extends ServiceError { ... } +final class StorageError extends ServiceError { ... } ``` **3.3.6: Result/Response Classes** @@ -1028,7 +1027,7 @@ class WifiNotifier extends AsyncNotifier { |-------|------|------| | **Service layer** | Any underlying exception (for conversion) | The only place allowed to `catch (e)` | | **Provider layer** | `ServiceError` only | MUST NOT import or catch underlying exceptions | -| **UI layer** | `ServiceError` only | Displays messages mapped from `ServiceError` types | +| **UI layer** | `ServiceError` only | Localizes via the central `localizeServiceError` mapper (Section 13.6) | **Purpose**: - **Isolate data layer implementation**: When the underlying protocol changes (e.g., USP → something else), only the Service layer's conversion logic needs updating — Provider and UI layers are unaffected @@ -1044,27 +1043,44 @@ class WifiNotifier extends AsyncNotifier { **Structure**: ```dart sealed class ServiceError implements Exception { - const ServiceError(); + /// Diagnostic raw fault code (firmware 7xxx/9xxx, WASM 9999, codegen 9998…). + /// For logging/debugging only — `null` when there is no code. + final int? code; + + /// Raw technical message (firmware text / WASM string). For logging/debugging. + /// Most subtypes derive their UI message from the type alone and ignore this; + /// fallback types like `UnexpectedError` may surface it. + final String? detail; + + const ServiceError({this.code, this.detail}); } -// All error types extend ServiceError -final class InvalidAdminPasswordError extends ServiceError { - const InvalidAdminPasswordError(); +// All error types extend ServiceError. Most carry no extra fields — the type +// itself is the semantic. They pass code/detail through to the base. +final class ResourceNotFoundError extends ServiceError { + const ResourceNotFoundError({super.code, super.detail}); } -final class InvalidResetCodeError extends ServiceError { - final int? attemptsRemaining; // Can carry additional information - const InvalidResetCodeError({this.attemptsRemaining}); +final class NetworkError extends ServiceError { + const NetworkError({super.code, super.detail}); } +// Fallback for unmapped errors — the one type whose UI message can't be derived +// from the type alone, so it may surface `detail`. final class UnexpectedError extends ServiceError { final Object? originalError; - final String? message; - const UnexpectedError({this.originalError, this.message}); + const UnexpectedError({this.originalError, super.code, super.detail}); } ``` -**Adding Error Types**: To add new error types, define them in `service_error.dart` following the `[ErrorType]Error` naming convention. +**`code` / `detail` are diagnostic only**: they carry firmware/WASM technical +context for logging and are NOT shown to users — the UI derives a localized +message from the subtype (Section 13.6). `UnexpectedError` is the sole exception. + +**Adding Error Types**: define them in `service_error.dart` following the +`[ErrorType]Error` naming convention. Because `ServiceError` is `sealed`, the +central UI mapper (Section 13.6) emits a compile-time warning until the new +subtype is given a localization. --- @@ -1136,9 +1152,9 @@ Future updatePassword(String newPassword) async { try { final svc = ref.read(wifiServiceProvider); await svc.updatePassword(newPassword); - } on InvalidAdminPasswordError { - // ✅ Handle known ServiceError subtype - state = AsyncError(const InvalidAdminPasswordError(), StackTrace.current); + } on InvalidInputError { + // ✅ Handle a known ServiceError subtype specially + state = AsyncError(const InvalidInputError(), StackTrace.current); } on ServiceError catch (e) { // ✅ Handle other ServiceErrors state = AsyncError(e, StackTrace.current); @@ -1156,6 +1172,7 @@ import 'package:privacy_gui/core/errors/service_error.dart'; // performFetch: catch ServiceError → return (null, errorStatus) // Do NOT rethrow — the mixin's fetch() handles null settings gracefully. +// Store the TYPED ServiceError in state (NOT '$e') so the View can localize it. @override Future<(DmzSettings?, DmzStatus?)> performFetch({ bool forceRemote = false, @@ -1166,7 +1183,7 @@ Future<(DmzSettings?, DmzStatus?)> performFetch({ return (settings, status); } on ServiceError catch (e) { logger.e('[USP][DMZ] Fetch failed', error: e); - return (null, DmzStatus(isLoading: false, errorMessage: '$e')); + return (null, DmzStatus(isLoading: false, error: e)); // typed, not '$e' } } @@ -1232,6 +1249,30 @@ All USP error parsing and `ServiceError` mapping is centralized in a single util --- +**Section 13.6: UI Layer Error Display** + +The UI layer is the **only** place that turns a `ServiceError` into a user-facing +string, and it does so through one central mapper — never by stringifying the error. + +**Rules**: +- **Localize via `localizeServiceError(context, error)`** — the single mapper that + switches on the sealed `ServiceError` and returns a localized message. Never show + `'$e'`, `error.toString()`, `code`, or `detail` to the user (those are diagnostic). +- **Fetch failure** → render the shared `ServiceErrorView` (it localizes internally). +- **Save failure** → `showFailedSnackBar(context, localizeServiceError(context, e))`. +- **Adding a subtype** requires adding its localization to the mapper (the `sealed` + switch enforces this at compile time) plus an ARB key. + +**Files**: `lib/components/localizations/service_error_localizations.dart` (mapper), +`lib/components/views/service_error_view.dart` (shared fetch-failure widget). + +> **Full implementation guidance** — per-layer patterns, what to show vs. hide, +> batch-failure handling, and a pre-PR checklist — lives in +> `doc/error-handling/error-handling-implementation-guide.md`. +> This Constitution states the principle; that guide is the how-to. + +--- + ## Article XIV: Layout Composition Patterns **Section 14.1: Definition and Scope** diff --git a/doc/error-handling/README.md b/doc/error-handling/README.md new file mode 100644 index 000000000..cbfe348d5 --- /dev/null +++ b/doc/error-handling/README.md @@ -0,0 +1,20 @@ +# Error Handling & Localization + +Documentation for USP error handling and error-message localization. One through-line: **how an error flows from firmware all the way to the UI, and how to implement error handling and achieve localization by following the existing patterns**. + +## Two documents + +| Document | Answers | When to read | +|---|---|---| +| 📘 [**Implementation Guide**](error-handling-implementation-guide.md)
`error-handling-implementation-guide.md` | **"How to do it"** — when adding a USP feature page: how to write error handling across the Service / Provider / View layers, what to show, what not to show, how to localize, and a pre-PR checklist | Read before you start implementing | +| 📗 [**Round-trip Reference**](usp-error-handling-reference.md)
`usp-error-handling-reference.md` | **"Why"** — the full round trip of an error from firmware through WASM / codegen to the UI, the data format at each layer, an exhaustive list of error sources / forms, the difference between 9999 / 7xxx / 9xxx / 9998, and the cause of the two paths | When you're confused or need to investigate a root cause | + +> **Suggested reading order**: read the **Implementation Guide** first (enough to write 80% of cases by following it). When you need to understand "why fetch and save have different error forms" or "how 9999 differs from 7xxx", then turn to the **Round-trip Reference**. + +## Source of the existing patterns + +The cross-cutting refactor of the error handling pipeline is in **PR #953** (`feat(l10n): centralize error message localization for USP features`). Every pattern in the Implementation Guide reflects the codebase as of after PR #953. + +## Known, not yet fixed + +- **GET 9999→9998 bug** (Round-trip Reference §2.5): a GET connection failure (9999) is disguised as an "invalid input" error (9998) at the transport layer. It is independent of localization and needs a separate fix — otherwise, no matter how good the l10n is, a GET connection failure will still be shown as "invalid input". Also summarized in the Implementation Guide §7 "Known limitations". diff --git a/doc/error-handling/error-handling-implementation-guide.md b/doc/error-handling/error-handling-implementation-guide.md new file mode 100644 index 000000000..8927665f2 --- /dev/null +++ b/doc/error-handling/error-handling-implementation-guide.md @@ -0,0 +1,475 @@ +# Error Handling Implementation Guide (USP Feature) + +> **This document answers "how to do it".** When adding a USP feature page, what pattern should error handling follow, what should be displayed, what should not be displayed, and how to achieve localization. +> **Background knowledge ("why")** — how errors flow from firmware up to the UI, the differences between 9999/7xxx/9xxx/9998, and the causes of the two paths — see [`usp-error-handling-reference.md`](usp-error-handling-reference.md). This document only references it when needed and does not restate it. +> **Source of the existing approach**: PR #953 (`feat(l10n): centralize error message localization for USP features`). All code examples in this document match the current codebase, not an idealized plan. + +--- + +## 0. One-Minute Overview (TL;DR) + +Errors flow up a single line, **with fixed responsibilities at each layer**: + +``` +Service → catch any error, convert to typed ServiceError, then throw + (fetch: map directly; save: self-throw Usp*FailureError first, then let the guard pass it through) + │ ServiceError object + ▼ +Provider → pass through only, no processing, no BuildContext, no string conversion + fetch: store ServiceError into state.error (or let it flow into AsyncValue.error) + save : rethrow + │ ServiceError object + ▼ +View → the only place that does localization + receive ServiceError → localizeServiceError(context, error) → localized string + fetch failure: empty-state widget; save failure: snackbar +``` + +**Three iron rules**: +1. **Service is the error conversion point**: what flows up is always a `ServiceError`, not a raw string, not an `Exception`. +2. **Provider only passes through the type**: never `'$e'` flattened into a string (once the type is lost, the View cannot localize). +3. **Only the View does localization**: through the single central mapper `localizeServiceError()`. Service/Provider have no `BuildContext`, nor should they. + +--- + +## 1. Service Layer: Convert All Errors to ServiceError + +The Service is the error "convergence point". Whether the underlying layer throws a string, an envelope, or a Dart exception, when it leaves the Service it is **always a `ServiceError`**. + +fetch and save are **two different patterns**, differing only by one guard. + +### 1.1 fetch (GET) — no guard + +Reference: [`usp_dmz_service.dart`](../../lib/page/dmz/services/usp_dmz_service.dart) `fetch()` + +```dart +Future<(DmzSettings, DmzStatus)> fetch() async { + try { + final dmzData = await Dmz.fetch(_usp); // codegen / WASM failure → throws raw string + final uiModel = buildUIModel(dmzData); // pure data assembly, never throws ServiceError + return (DmzSettings(model: uiModel, ...), const DmzStatus(isLoading: false)); + } catch (e) { + throw mapUspErrorToServiceError(e); // map directly, no guard needed + } +} +``` + +**Why no guard is needed**: in fetch's try block, the only things thrown are codegen / WASM raw strings, plus pure data assembly (which does not throw ServiceError). The `e` received by `catch` cannot be a ServiceError, so no check is necessary. + +### 1.2 save (SET / ADD / DELETE) — has the `is ServiceError` guard + +Reference: [`usp_dmz_service.dart`](../../lib/page/dmz/services/usp_dmz_service.dart) `update()` / `add()` + +```dart +Future update({required String instancePath, required DmzUIModel model}) async { + try { + final result = await Dmz.update(_usp, [DmzEntryUpdate(...)]); + switch (UspResultParser.parseSetResult(result)) { + case UspSuccess(): + break; + case UspPartialSuccess(:final errorSummary, :final successes, :final failures): + throw UspPartialFailureError( // ← Service itself throws a ServiceError + summary: 'DMZ update partial failure: $errorSummary', + successPaths: successes.map((s) => s.requestedPath).toList(), + failures: failures, // full List, do not store only path + ); + case UspFailure(:final errorSummary, :final errors): + throw UspCompleteFailureError( // ← same as above + summary: 'DMZ update failed: $errorSummary', + failures: errors, + ); + } + } catch (e) { + if (e is ServiceError) rethrow; // guard: pass self-thrown ServiceError through as-is + throw mapUspErrorToServiceError(e); // only the remaining raw strings get mapped + } +} +``` + +**Why the guard is needed**: save parses the batch envelope and **actively `throw UspPartialFailureError` / `UspCompleteFailureError` (already ServiceError)**. Without the guard, these self-thrown ServiceErrors would be caught by the outer catch and then passed into `mapUspErrorToServiceError`, where, not matching the `"{Op} failed:"` format, they would be mis-wrapped as `UnexpectedError`, losing all semantics. The guard lets "the ServiceError you threw yourself bubble up as-is". + +> **In one sentence**: the guard = the indicator of "whether the try block self-throws a ServiceError". If yes (save) → guard needed; if no (fetch) → not needed. + +### 1.3 batch failures must always store the full `failures` + +`UspPartialFailureError` / `UspCompleteFailureError` are **containers** holding `List failures` (full path + errorCode + errorMessage). + +- ✅ **Store the entire `failures` list** (pass it directly from the `failures`/`errors` of `UspPartialSuccess`/`UspFailure`). +- ❌ **Do not store only the path string** — that loses the errorCode, and the View cannot localize by code. +- `failedPaths` is a derived getter (`failures.map((f) => f.requestedPath)`), backward-compatible; you don't need to assemble it yourself. + +### 1.4 Do not hardcode "user-facing" error messages in the Service + +The Service has no `BuildContext`, so it **should not assemble any copy meant for the user to see**. +- The `summary` field is an English summary **for log / debug use**; it is never shown to the user (the View does not read it). +- The only "text" that flows from the Service to the UI is `UnexpectedError.detail` (the View displays it as a fallback) — but that is a diagnostic string, not UI copy you hardcoded. + +> ⚠ **field-level form validation is a separate line**, do not confuse it. The `Map` returned by `validateForm()` (e.g. `{'destIp': 'Invalid IP address'}`) is field-level validation that goes through `status.fieldErrors`, **not a ServiceError**. Its localization belongs to the general form string scope and is not part of this document's error handling pipeline. + +--- + +## 2. Provider Layer: Pass Through Only, No Processing + +The Provider's only responsibility is to **pass the `ServiceError` given by the Service up untouched**. No `BuildContext`, no string conversion, no copy assembly, no further calls to `mapUspErrorToServiceError`. + +There are two forms depending on the page architecture. + +### 2.1 Preservable / Notifier pages (state.error) + +Reference: [`usp_dmz_notifier.dart`](../../lib/page/dmz/providers/usp_dmz_notifier.dart) + +**fetch failure → store into `state.error` (typed, not a string)**: + +```dart +@override +Future<(DmzSettings?, DmzStatus?)> performFetch({...}) async { + try { + final (settings, status) = await _svc.fetch(); + return (settings, status); + } on ServiceError catch (e) { // always catch the ServiceError type + logger.e('[USP][...][DMZ]: Fetch failed', error: e); + return (null, DmzStatus(isLoading: false, error: e)); // store the object, not '$e' + } +} +``` + +**save failure → rethrow** (the framework's `save()` is transparent and does not catch, letting the ServiceError pass straight through to the View): +the save path does not need its own catch; `PreservableAutoDisposeNotifierMixin.save()` lets the ServiceError thrown by the Service propagate up directly. + +The corresponding state model: + +```dart +class DmzStatus extends Equatable { + /// Typed error from the last fetch. View localizes it via localizeServiceError. + final ServiceError? error; // ✅ not String? errorMessage + + DmzStatus copyWith({ + ServiceError? error, + bool clearError = false, // ← see the pitfall below + ... + }) => DmzStatus( + error: clearError ? null : (error ?? this.error), + ... + ); +} +``` + +> ⚠ **Pitfall: clearing the error in `copyWith` must use the `clearError` flag**. Dart's `error ?? this.error` cannot distinguish "not passed" from "passed null", so clearing the error state (e.g. before a re-fetch) must go through the explicit `clearError: true`, and cannot rely on passing `error: null`. + +### 2.2 AsyncNotifier pages (AsyncValue.error) + +Reference: [`usp_admin_notifier.dart`](../../lib/page/admin/providers/usp_admin_notifier.dart) + +These pages use `AsyncNotifier`, letting the error flow directly into `AsyncValue.error`: + +```dart +class UspAdminNotifier extends AutoDisposeAsyncNotifier { + @override + Future build() async { + try { + final adminUser = await _svc.fetchAdmin(); + return UspAdminState(...); + } on ServiceError catch (e) { + logger.e(...); + rethrow; // rethrow → into AsyncValue.error, View catches it with .when(error:) + } + } +} +``` + +> **How to choose**: follow the page's existing state architecture; do not change the architecture for the sake of error handling. +> - Pages using `FeatureState` / `Preservable` → 2.1 (state.error). +> - Pages using `AsyncNotifier` → 2.2 (AsyncValue.error). +> Both render a full-page fetch failure with the same `ServiceErrorView` in the View (see §3.1); only "where the error is stored" and "how retry is triggered" differ. (Compact cards / embedded widgets are the exception — see §3.1.1.) + +--- + +## 3. View Layer: The Only Place That Does Localization + +The View receives the `ServiceError` and passes it to the central mapper [`localizeServiceError(context, error)`](../../lib/components/localizations/service_error_localizations.dart) to get the localized string. **This is the only place in the entire codebase that turns an error type into a display string.** + +### 3.1 Displaying fetch failures → full-page `ServiceErrorView` + +For a **full-page** fetch failure, both page architectures render with the **same** shared +widget [`ServiceErrorView`](../../lib/components/views/service_error_view.dart). Only two +things differ between them: where the error comes from, and how retry is triggered. + +`ServiceErrorView` calls `localizeServiceError` internally; you don't translate the detail +yourself. It shows: an error icon + a **`title`** + the localized detail + a retry button +(and an optional secondary action — see below). + +> **Always pass a context-appropriate `title`.** `title` is optional and defaults to a +> neutral `errorUnexpected` ("Something went wrong") — but you SHOULD pass a page-specific +> title so the user sees something meaningful. Settings pages pass +> `loc(context).failedToLoadSettings`; other pages pass their own key +> (`unableToLoadTopology`, `unableToGatherDeviceInfo`, …). The neutral default only exists +> so a forgotten `title` degrades gracefully instead of showing a wrong "Failed to load +> settings" on a non-settings page. + +**(A) state.error pages** — pass `status.error` directly (it is already a `ServiceError?`): + +```dart +if (status.error != null) { + return ServiceErrorView( + error: status.error, + title: loc(context).failedToLoadSettings, + onRetry: () => ref.read(uspDmzProvider.notifier).fetch(forceRemote: true), + ); +} +``` + +**(B) AsyncValue pages** — inside `.when(error:)`, the callback hands you an `Object error`, +so narrow it with `error is ServiceError ? error : null`; retry re-runs `build()` via +`ref.invalidate`: + +```dart +asyncState.when( + loading: () => const Center(child: AppLoader()), + error: (error, stack) => ServiceErrorView( + error: error is ServiceError ? error : null, + title: loc(context).unableToGatherDeviceInfo, + onRetry: () => ref.invalidate(devicesDataProvider), + ), + data: (state) => _buildContent(context, ref, state), +); +``` + +> **Why the narrow?** `ServiceErrorView.error` is `ServiceError?`, but `AsyncValue.when(error:)` +> gives `Object`. `ServiceErrorView` accepts `null` (it then shows just the title), so a +> non-ServiceError degrades safely. + +**Optional secondary action.** When a page needs an escape hatch (e.g. the dashboard's +"Log out" when it cannot load at all), pass `secondaryLabel` + `onSecondary` (both together +— an `assert` enforces the pair): + +```dart +ServiceErrorView( + error: error is ServiceError ? error : null, + title: loc(context).failedToLoadSettings, + onRetry: () => ref.read(dashboardOrchestratorProvider.notifier).refreshAll(), + secondaryLabel: loc(context).logout, + onSecondary: () => _logout(context, ref), +); +``` + +### 3.1.1 Compact / embedded error states — NOT `ServiceErrorView` + +`ServiceErrorView` is a **full-page** empty state (48px icon, large title, filled button). +Do **not** use it inside a height-constrained container — it overflows. For those, write a +compact inline error widget, but **still localize via `localizeServiceError`**: + +- **Dashboard cards** (constrained `DashboardCardTemplate` height): e.g. + [`usp_speed_test_card.dart`](../../lib/page/unified_diagnostics/cards/usp_speed_test_card.dart) + uses a 32px icon + `bodySmall` + text button. +- **Embedded widgets** without a retry context: e.g. + [`health_status_view.dart`](../../lib/page/dashboard/mascot/widgets/health_status_view.dart) + shows a single localized line. + +The invariant is the same as everywhere: the shown string comes from `localizeServiceError` +(or a localized key), never a raw `'$e'`. + +### 3.2 Displaying save failures → snackbar + +Reference: [`usp_dmz_view.dart`](../../lib/page/dmz/views/usp_dmz_view.dart) `_onSave` + +The common form is **try/catch wrapping `notifier.save()`**: + +```dart +Future _onSave(BuildContext context, WidgetRef ref) async { + try { + await doSomethingWithSpinner(context, ref.read(uspDmzProvider.notifier).save()); + if (context.mounted) { + showSuccessSnackBar(context, loc(context).dmzSettingsSaved); + } + } catch (e) { + if (context.mounted) { + showFailedSnackBar(context, localizeServiceError(context, e)); // ← localize + } + } +} +``` + +- ✅ `showFailedSnackBar(context, localizeServiceError(context, e))` +- ❌ `showFailedSnackBar(context, 'Failed to save: $e')` (flattening the string directly, not localized) + +`showFailedSnackBar` / `showSuccessSnackBar` have the signature `(BuildContext, String)` — they take an already-translated string and are not responsible for translation. + +> **The point is "the string always goes through `localizeServiceError`"; which snackbar API you use is secondary.** Most pages use the shared `showFailedSnackBar` (recommended); a few pages (such as [`instant_privacy_view.dart`](../../lib/page/instant_privacy/views/instant_privacy_view.dart)) directly use `ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(localizeServiceError(context, e))))`. Both are correct — the key invariant: **the displayed string comes from `localizeServiceError`, not raw `'$e'`**. + +### 3.3 dashboard card mutation → use the shared helper `performUspMutation` + +The inline action buttons on a dashboard card (network status, WiFi, port forwarding…) — such as "renew lease" or "add reservation" — do **not** write their own try/catch, but call the shared helper [`performUspMutation`](../../lib/page/_shared/components/usp_mutation_helper.dart). It wraps "set loading state → run mutation → success/failure snackbar" into a single entry point: + +```dart +// Reference: the "renew lease" button in usp_network_status_card.dart +onTap: () => performUspMutation( + context, + ref, + loadingKey: 'wanRenew', // corresponds to uspMutationLoadingProvider + mutation: () => ref.read(uspInternetSettingsProvider.notifier).renewDhcpLease(), + successMessage: loc(context).xxx, // pass an "already loc()'d string" +), +``` + +**This is not a "third localization strategy" — it is just a convenience entry point for the card to trigger a mutation.** On failure the helper already calls `localizeServiceError` internally, so: + +- ✅ **You don't need to localize the failure message yourself** — just pass `mutation`, and the helper will localize the thrown `ServiceError` before displaying it. +- ⚠ **`successMessage` is displayed as-is** (the helper does not translate it) — so **the caller must pass an already-`loc()`'d string**, not hardcoded English. +- Applicable scenario: a single inline action on a dashboard card (not a full-page form save). A full-page form save still goes through the view try/catch in §3.2. + +> This helper is the encapsulation, in the card scenario, of the "Service → Provider → View" main line in §0: inside the mutation it still passes through the Service (throws ServiceError) → Provider (rethrow) → the helper's catch (`localizeServiceError`). + +--- + +## 4. What to Display / What Not to Display + +This is the most important principle in this document. Error information serves two purposes, **always kept separate**: + +| | For the user (UI) | For the engineer (log/debug) | +|---|---|---| +| Content | a single l10n sentence determined by the **ServiceError type** | `code` (fault code), `detail` (firmware original text / WASM technical string) | +| How it arrives | `localizeServiceError(context, error)` | `logger.e(..., error: e)`, `'$e'` (toString) | +| Localization | ✅ always (26 locales) | ❌ not localized (firmware English technical string, untranslatable) | + +**Rules**: +1. **The message shown in the UI is determined by the type**, not the `detail` / `code`. The user sees "this setting could not be found", not `'Unexpected error: ...(code: 7026)'`. +2. **`detail` / `code` go only into the log**. They are diagnostic raw material, firmware English technical strings; shown to the user they are neither understandable nor localizable. +3. **The only exception: `UnexpectedError`**. It is the fallback, with no type semantics, so `localizeServiceError` displays its `detail` (if any), otherwise falling back to `errorUnexpected`. This is a deliberate compromise — an unmapped error gives at least some clue. + +### 4.1 What the central mapper looks like (`localizeServiceError`) + +```dart +String localizeServiceError(BuildContext context, Object error) { + final l = loc(context); + if (error is! ServiceError) return l.errorUnexpected; // defensive + return switch (error) { + NotAuthenticatedError() => l.errorNotAuthenticated, + InvalidCredentialsError() => l.errorInvalidCredentials, + SessionTokenExpiredError() => l.errorSessionExpired, + InvalidSessionTokenError() => l.errorInvalidSessionToken, + UnauthorizedError() => l.errorUnauthorized, + ResourceNotFoundError() => l.errorResourceNotFound, + InvalidInputError() => l.errorInvalidInput, + NetworkError() => l.errorNetwork, + ConnectivityError() => l.errorConnectivity, + TimeoutError() => l.errorTimeout, + ServiceNotInitializedError() => l.errorServiceNotReady, + // batch: display the specific error of the first entry (see §4.2) + UspPartialFailureError(:final failures) => _localizeBatch(context, failures), + UspCompleteFailureError(:final failures) => _localizeBatch(context, failures), + // fallback: the only type that displays detail + UnexpectedError(:final detail) => detail ?? l.errorUnexpected, + StorageError() => l.errorUnexpected, // never reaches UI (intercepted at session/auth layer) + SerialNumberMismatchError() => l.errorUnexpected, // same as above + }; +} +``` + +The `switch` is **exhaustive** over the sealed `ServiceError` — this is a design point: when a subclass is added, the compiler warns that a case is missing here, **forcing you to add the l10n**, so nothing is missed. + +### 4.2 batch errors: display the specific message of the first entry + +`Usp*FailureError` holds multiple `failures`. **Strategy: always display the specific error of `failures.first`** (translated into the corresponding l10n sentence by its `errorCode`). + +- ❌ Do not use "N settings failed" — vague, and the user has no way to fix it. +- ✅ Display the specific error of the first entry; if there is still a second entry after the user fixes it, the next save will display the next one — still traceable. + +```dart +String _localizeFaultCode(BuildContext context, int code) => switch (code) { + 7004 || 7005 || 7006 || 9008 => loc(context).errorInvalidInput, + 7026 || 7027 || 9005 || 9007 => loc(context).errorResourceNotFound, + 9001 => loc(context).errorUnauthorized, + 9999 => loc(context).errorNetwork, + _ => loc(context).errorUnexpected, // unknown vendor code does not leak the original text +}; +``` + +--- + +## 5. Achieving Localization + +1. **Framework**: the project uses `flutter_localizations` (`loc(context).xxx`), **not slang**. (Migrating to slang was evaluated once; because the "translation without context" need was measured at 0 in practice and the migration cost was high, the ROI was negative → keep the status quo. All error copy is translated in the View layer with context.) +2. **ARB key**: the key for error messages is in `lib/l10n/app_en.arb`, named `errorXxx` (camelCase). The existing 12 generic keys: + ``` + errorNotAuthenticated, errorInvalidCredentials, errorSessionExpired, + errorInvalidSessionToken, errorUnauthorized, errorResourceNotFound, + errorInvalidInput, errorNetwork, errorConnectivity, errorTimeout, + errorServiceNotReady, errorUnexpected + ``` + Plus the shared: `failedToLoadSettings`, `retry`. +3. **Multi-language**: after adding the English key to `app_en.arb`, also add translations for the other 25 locales (`app_es.arb` / `app_ja.arb` …). + +**In most cases you don't need to add an error key** — the existing 12 types already cover common errors. You only need to when adding a ServiceError subclass (see §6). + +--- + +## 6. Adding a ServiceError Subclass (Rare) + +Only do this when none of the existing types can express a certain error semantic. Steps: + +1. In [`service_error.dart`](../../lib/core/errors/service_error.dart) add `final class XxxError extends ServiceError`, with `{super.code, super.detail}`. +2. **Compile**: the `switch` in `localizeServiceError` will immediately warn that a case is missing (sealed enforcement). +3. In `app_en.arb` add the corresponding `errorXxx` key (+ translations for the other locales). +4. In the `switch` add `XxxError() => l.errorXxx`. +5. At the appropriate place in the Service layer `throw XxxError(...)`, or add the mapping in `mapUspErrorToServiceError`. + +> ⚠ Before adding, think it through: do you "really need a new type" or is "an existing type + a different l10n sentence" enough? A type is for "the whole app treating it consistently", not for customizing copy for a single page. + +--- + +## 7. Important Notes and Pitfalls (Do & Don't) + +| Do ✅ | Don't ❌ | +|---|---| +| Service `catch → throw mapUspErrorToServiceError(e)` (fetch) | Service throws a raw string / `Exception` up | +| Service save uses the `if (e is ServiceError) rethrow` guard | save misses the guard → the self-thrown ServiceError is mis-wrapped as UnexpectedError | +| batch stores the full `failures` list | stores only the `failedPaths` string (errorCode lost) | +| Provider fetch stores `state.error = e` (typed) | Provider `errorMessage: '$e'` (type lost, cannot localize) | +| `copyWith` clears error with `clearError: true` | passing `error: null` to clear it (eaten by `?? this.error`, not cleared) | +| View always `localizeServiceError(context, e)` | View hardcodes `'Failed to save: $e'` / `'Unable to load X'` | +| `ServiceErrorView` gets a context-appropriate `title` | relying on the neutral default title on a specific page | +| compact cards / embedded widgets use an inline widget (still localized) | putting the full-page `ServiceErrorView` in a height-constrained card (overflow) | +| `detail`/`code` go only into `logger.e(..., error: e)` | showing `detail`/`code` to the user (firmware English technical string) | +| follow the page's existing state architecture to choose §3.1 (A) or (B) | change the page architecture for the sake of error handling | + +### Known Limitations + +- **GET 9999→9998 bug (unfixed)**: a GET connection failure (9999, which should be "network error") is disguised as 9998 at the transport layer's 5th layer → ultimately localized as "input error" (`errorInvalidInput`). This is a transport layer bug, independent of localization. Until it is fixed, **a GET failure's "input error" message may actually be a connection issue**. For the root cause see [`usp-error-handling-reference.md`](usp-error-handling-reference.md) §2.5. +- **`_localizeFaultCode` and `_mapProtocolError` must stay in sync**: the fetch path (string → `_mapProtocolError` in `mapUspErrorToServiceError`) and the save batch path (envelope → `_localizeFaultCode` in `localizeServiceError`) must give consistent results for the same firmware code. Change one and the other must change too. `_localizeFaultCode`'s doc comment already states "Mirrors `_mapProtocolError` … MUST stay in sync"; the reverse direction (the `usp_error.dart` side) currently **has no** back-pointing reminder, so when changing `_mapProtocolError` you must remember to go back and sync `_localizeFaultCode` yourself. + +### Out of This Pipeline's Scope + +- **firmware_update**: has a lot of flow copy, with its own exception + state-driven error display; a separate scope. +- **SSE subscription errors**: a separate error path (server push), does not go through this pipeline. +- **instant_setup (pnp_* wizard)**: still uses its own `errorMessage` + `ref.listen` display, not incorporated. +- **apps page**: fetches lighttpd static JSON (not USP/TR-181) and throws plain `Exception`, not `ServiceError`. It deliberately keeps its own error widget (showing a localized `unableToLoadApps`, not the raw exception) rather than `ServiceErrorView`. +- **field-level form validation**: `validateForm`'s `Map` goes through `fieldErrors`, not ServiceError (see §1.4). + +--- + +## 8. Pre-PR Checklist + +- [ ] Service fetch `catch → throw mapUspErrorToServiceError(e)`; save has the `is ServiceError` guard. +- [ ] batch failure stores the full `failures` list (not only the path). +- [ ] state model uses `ServiceError? error`, not `String? errorMessage`; `copyWith` has `clearError`. +- [ ] Provider has no `'$e'` / `errorMessage: '...'`; fetch stores the type, save rethrows. +- [ ] View's full-page fetch failure renders `ServiceErrorView` with a context-appropriate `title` (state.error pages pass `status.error`; AsyncValue pages pass `error is ServiceError ? error : null` inside `.when(error:)`). Height-constrained cards / embedded widgets use a compact inline widget instead, still via `localizeServiceError`. +- [ ] View's save failure goes through `showFailedSnackBar(context, localizeServiceError(context, e))`. +- [ ] dashboard card inline actions use `performUspMutation` (it already localizes failures automatically), with `successMessage` passing an already-`loc()`'d string. +- [ ] No hardcoded English error strings (`'Unable to load...'`, `'Failed to save: $e'`, `'Error: $e'`). +- [ ] If adding a ServiceError subclass: add the ARB key (including the other locales) + the `switch` case. +- [ ] `flutter analyze` has no warnings (especially the sealed switch's exhaustiveness). + +--- + +## Appendix: Key Files + +| File | Role | +|---|---| +| [`lib/core/errors/service_error.dart`](../../lib/core/errors/service_error.dart) | sealed `ServiceError` type definition | +| [`lib/core/usp/errors/usp_error.dart`](../../lib/core/usp/errors/usp_error.dart) | `mapUspErrorToServiceError` (string → ServiceError) | +| [`lib/components/localizations/service_error_localizations.dart`](../../lib/components/localizations/service_error_localizations.dart) | `localizeServiceError` (central mapper, the only localization point) | +| [`lib/components/views/service_error_view.dart`](../../lib/components/views/service_error_view.dart) | `ServiceErrorView` (shared empty-state widget for fetch failures) | +| [`lib/page/dmz/`](../../lib/page/dmz/) | Type A (state.error) full reference: service / notifier / view | +| [`lib/page/admin/`](../../lib/page/admin/) | AsyncValue (AsyncValue.error) reference | +| `lib/l10n/app_en.arb` | error message ARB key (`errorXxx`) | diff --git a/doc/error-handling/usp-error-handling-reference.md b/doc/error-handling/usp-error-handling-reference.md new file mode 100644 index 000000000..07d17bb98 --- /dev/null +++ b/doc/error-handling/usp-error-handling-reference.md @@ -0,0 +1,482 @@ +# USP Full-Chain Reference: Data Format, Error Enumeration, Error Handling Mechanism + +> Source: `PrivacyGUI`(Dart) + `usp_framework/usp-client`(Rust/WASM). The firmware-side `usp-bridge` / `OBUSPA` are unreadable, treated as a black box. +> This doc covers three things (background knowledge, answering "why"): **(1) what a request looks like at each layer (2) exhaustive enumeration of every error WASM can throw (3) the rationale behind the existing error handling patterns**. +> For "how to implement it" see the [implementation guide](error-handling-implementation-guide.md). + +> **Where in the codebase** +> - **Diagnostic fields**: the `ServiceError` base class carries `code` / `detail`; the 5 subclasses use a unified `detail` (no longer their own `message` each); each `_mapXxx` in `mapUspErrorToServiceError` carries `code`+`detail`; `UspCompleteFailureError`/`UspPartialFailureError` store `List failures` (`failedPaths` is a derived getter). The fault code / raw message therefore never get lost on the way to ServiceError, usable for both UI and log. +> - **localization**: the View produces a localized message based on the ServiceError type (central mapper `localizeServiceError`), implemented in PR #953. For how to write it see the [implementation guide](error-handling-implementation-guide.md). +> - **§2.5 GET bug (known, not yet fixed)**: a 9999 GET failure is disguised as 9998. + +--- + +## Layer Quick Reference (main line: USP read/write = HTTP POST, not WebSocket) + +**Set the full picture straight first**: at the bottom this project talks to the router over only **HTTP / SSE / WebSocket** transports (SSE is essentially a long-lived HTTP connection; Bluetooth is listed in `pubspec.yaml` but is not actually used in `lib/`). +But for the topic "how a USP request goes out, and how errors come back", there is **only one main line, plus two side branches** — they are not equal "pick one of three" options; they are different dimensions: + +**Main line (request / response) — almost all of §1–§3 talks about this one:** +Every feature's get/set/add/delete/operate goes through `UspClient`(WASM) → HTTP POST `/api/v1/usp`. +``` +1 Notifier/Provider Dart mutation lock, ref.listen invalidation +2 Service Dart business logic + error mapping (mapUspErrorToServiceError) ◄ error contract boundary +3 Codegen (.g.dart) Dart TR-181 type ↔ Dart, assemble full path +4 UspClient facade Dart throttler(GET dedup), 401 retry, value stringify, GET coerce +5 UspClientWeb Dart Dart↔WASM (jsify/dartify) +6 JS glue JS window.UspClient, WASM loading +─────────────────────── WASM boundary ─────────────────────── +7 wasm/mod.rs Rust exported fn, JS value→struct, two error strategies +8 client.rs Rust msg_id, command_key, ResponsePool correlation +9 protocol Rust protobuf encode/decode, Record/Msg +10 transport/http.rs Rust POST /api/v1/usp (fetch), Bearer token +─────────────────────── readable right edge ─────────────────────── +11 usp-bridge (black box) → 12 OBUSPA (black box) → 13 TR-181 data model +``` + +**Side branch 1 — SSE notifications (push, not request/response)**: invalidation / events **proactively pushed** by firmware, +`UspBridgeClient.notifications()` reads `/api/v1/notifications` with Fetch + ReadableStream, **pure Dart, does not go through WASM**. +It is not "another path for sending a request" but another interaction mode (server→frontend). It is a different story from "error mapping"; just be aware it exists. + +**Side branch 2 — WebSocket (special case, separate class)**: used **only** for firmware upload, +`UspWsClientWrapper` connects to `wss://.../usp-ws` (the WASM holds the socket); across the whole codebase only `firmware_ws_upload_strategy.dart` uses it, and it has its own exception handling. The rest of this doc does not touch it. + +> In one sentence: **just follow the main line**; SSE is "receive push", WebSocket is "transfer firmware", neither is on the error-mapping topic of §1–§3. + +--- + +# 1. Request Data Format: What Each Layer Looks Like + +Take **DMZ** as the example (SET: enable DMZ pointing to `192.168.1.150`). + +### Downstream (request going out) + +| Layer | Data shape | File | +|---|---|---| +| **2 Service input** | typed object, a null field = "not set this time"
`DmzEntryUpdate(instancePath:'Device.Firewall.DMZ.1.', enable:true, destIp:'192.168.1.150', sourcePrefix:'0.0.0.0/0')` | `usp_dmz_service.update` | +| **3 Codegen assembles path** | `Map`, key=full TR-181 path, value is still a **native type**
`{'Device.Firewall.DMZ.1.Enable': true, '...DestIP': '192.168.1.150', '...SourcePrefix': '0.0.0.0/0'}` | `dmz.g.dart Dmz.update` | +| **4 UspClient stringify** | `Map`, all values converted to strings
`{'...Enable': 'true', '...DestIP': '192.168.1.150', ...}` | `usp_client.dart _batchSet` | +| **5 → JS** | `parameters.jsify()` → JS object; options `allowPartial:false` → **`undefined`** | `usp_client_wasm.dart UspClientWeb.set` | +| **7 Rust parse** | `Vec<(String,String)>` (⚠ must be stringified first, otherwise `as_string()` returns None → becomes `"JsValue(true)"`) | `wasm/mod.rs UspClient::set` | +| **9 protobuf** | grouped **by parent path** (split at the last `.`) into `UpdateObject`:
`Msg{Header{msg_id:uuid, msg_type:SET}, Body{Set{allow_partial:false, update_objs:[{obj_path:'Device.Firewall.DMZ.1.', param_settings:{Enable:'true', DestIP:'192.168.1.150', SourcePrefix:'0.0.0.0/0'}}]}}}` | `encode.rs encode_set_request` / `extract_object_and_param` | +| **10 on the wire** | `POST /api/v1/usp` / `Content-Type: application/octet-stream` / `Authorization: Bearer ` / body=binary protobuf (**bare Msg, no Record**, Record is added by the bridge) | `http.rs post`(wasm) / `post_protobuf` | + +### Upstream (response coming back) — unified envelope + +The return is always the WASM v0.11.0 unified format: `{ success, result: { data, error? } }`. **`error` is omitted entirely when there is no error; on partial failure `success:true` but it carries `error`** — so even with `success===true` you must still check `error`. + +```js +// SET success +{ success: true, result: { data: { "Device.Firewall.DMZ.1.Enable": "true", ... } } } + +// SET partial(SourcePrefix value invalid) +{ success: true, result: { + data: { "Device.Firewall.DMZ.1.Enable": "true", "...DestIP": "192.168.1.150" }, + error: { "Device.Firewall.DMZ.1.SourcePrefix": { errorCode: 7006, errorMessage: "..." } } } } + +// transport failure(401/timeout) +{ success: false, result: { error: { "": { errorCode: 9999, errorMessage: "Transport error: ..." } } } } +``` + +| Layer | Action | File | +|---|---|---| +| **10 dartify** | JS object → `Map` (recursive coerce) | `usp_client_wasm.dart UspClientWeb.set` | +| **11 UspClient** | returns as-is (only logs); codegen also returns it as-is to the service | `usp_client.dart` | +| **12 Service parse** | `UspResultParser.parseSetResult(map)` → `UspSuccess` / `UspPartialSuccess` / `UspFailure` | `usp_operation_result.dart _parseGenericResult` | + +### get/set is "synchronous HTTP round-trip", only operate uses SSE + +- **get / set / add / delete**: `client.rs`'s `send_usp(...).await` directly gets the **complete protobuf body of the same HTTP response** (`post` returns `response.bytes()`), and decodes the data on the spot with `decode_*_response`. **Never touches SSE the whole way; one HTTP round-trip and it's done.** +- **Purpose of `response_pool` (msg_id correlation)**: when **responses of concurrent HTTP requests come back out of order**, it uses msg_id to pair "which body belongs to which request". It is an internal mechanism of synchronous req/resp, unrelated to SSE. +- **operate splits into synchronous/asynchronous**: + - **Synchronous operate (the majority, e.g. reboot, factory reset)**: the result is directly in the HTTP response's `OperSuccess.output_args`, **same as get/set: one round-trip, no SSE**, calling `UspClient.operate` directly. + - **Asynchronous operate (the minority, only diagnostics: ping/traceroute/nslookup/download)**: HTTP only returns an **ACK** (protobuf `operate_resp = None`, containing the client-generated commandKey); the real result is obtained by the Dart `SseOperationAwaiter` (`network_diagnostics_executor`) waiting for the SSE `OperationComplete` event (Direct Data Delivery). + - **SSE waiting applies only to this one category**; get/set/add/delete and all other operate do not apply. + +> ⚠ **GET and SET error handling are asymmetric**: SET preserves the envelope and goes through `UspResultParser`; GET, on the other hand, discards the envelope in `UspClientWeb.get` and keeps only the flattened `data`, so a failure only surfaces when WASM itself throws a string. This asymmetry causes a major defect (a 9999 GET failure is disguised as 9998) — **for the full causality see §2.5**. + +--- + +# 2. Error Source and Form (enumeration → ServiceError mapping spec) + +### 2.0.0 Core Overview: error "source" and "form" are two independent axes ⚠⚠ + +To understand the whole error model, the most important thing is to **not tie together "who produces the error" and "what form the error takes"**. They are two independent axes: + +**Axis one — source (who produces it) has 3:** +1. **Rust WASM client** (layers 7–10) +2. **firmware** (bbfdm / OBUSPA, Rust only passes through) +3. **Dart codegen** (`lib/generated/*.g.dart`) + +**Axis two — form (what it looks like) has 2:** +- **A. throws a string** (Promise reject / Dart throw): `"{Op} failed: {category}: {detail}"` +- **B. structured envelope**: `{success, result:{data, error?{path:{errorCode, errorMessage}}}}` + +**Key point: both forms have different sources mixed in — you cannot say "WASM=string, firmware=envelope".** The real correspondence is: + +| Source | Form | code / identification | Meaning | +|---|---|---|---| +| **WASM** (lifecycle methods login/logout/subscribe…) | **throws a string** | `"Login failed: ..."` and other prefixes | client-side failure | +| **WASM** (data operations get/set… on failure) | **envelope** | **9999** | request **did not reach firmware** (network/auth/encoding) | +| **firmware** (passed through) | **envelope** (when GET goes through codegen it may also be inside the `(code:)` of a thrown string) | **7xxx / 9xxx** | **reached firmware, but was rejected by it** | +| **firmware** (passed through) | **envelope** | `success:true` | **firmware processed it successfully** | +| **Dart codegen** | **throws a string** | **9998** | GET response is missing a required field | + +> **"Lifecycle methods"** refers to methods that manage "the session/connection itself" rather than reading/writing router data: login/logout/refreshToken/subscribe/unsubscribe. +> They are not called by the UI directly — login/logout are triggered in tandem by `UspAuthCoordinator` along with the **local login flow** (the user entering the router password): after a successful local login it logs USP in with the same password, and on app restart it `restoreSession`s with the stored password. subscribe/unsubscribe are managed internally and automatically by the SSE system (`SseManager`). + +--- + +### 2.0 Boundary Rule: throws a string vs structured envelope —— **split by "which method", not by "error type"** ⚠ + +This is the most misunderstood part of the whole error model. You have to look at **two handoff points**: **(1) the format WASM returns to Dart** and **(2) the format the Service gets from Dart's layer 5 (`UspClientWeb`)**. The two differ, and GET especially shows it. + +**(1) The format WASM returns to Dart** — each method hard-codes its failure form in `wasm/mod.rs`; the boundary is **by "which method"**, not by error type: + +| Method group | Failure form WASM returns | Even for network/auth/timeout errors? | Source | +|---|---|---|---| +| **Lifecycle methods**: `login` / `logout` / `refreshToken` / `subscribe` / `unsubscribe` / `listSubscriptions` / constructor | **A. throws a string** (Promise reject) | yes, always throws a string | each method's `Err(e) => Err(JsValue::from_str(...))` | +| **Data operations**: `get` / `set` / `setOrdered` / `add` / `delete` / `operate` | **B. structured envelope** (`{success:false or partial, result:{error}}`, code 9999) | **yes! network/auth errors are also wrapped into an envelope (9999), not thrown as a string** | `set`'s `Err(e) => build_transport_error_unified(...)` | + +Evidence (the two branches of `UspClient::set` in `wasm/mod.rs`, get is the same): +```rust +match client.set_with_options(params_vec, ...).await { + Ok(response) => serialize_set_response_to_js(&response), // → envelope, code decided by firmware (7xxx/9xxx/success) + Err(e) => build_transport_error_unified(&path_list, ...),// → envelope, code hard-coded 9999 (even timeout/401 goes here) +} +``` + +**(2) The format the Service gets from Dart's layer 5** — Dart's layer 5 (`UspClientWeb`) reprocesses it, making the form the service gets **different from what WASM returned**; this is the root of GET's imprecision: + +| Operation | Failure form the service gets from layer 5 | +|---|---| +| `set` / `setOrdered` / `add` / `operate` | **keeps the envelope** (parsed by `UspResultParser`) | +| `delete` | the WASM exception is synthesized into an envelope by layer 5 (still envelope) | +| **`get`** | ⚠ **becomes a string**: layer 5 takes only `result.data` and discards the envelope's `success`/`error`; on failure the service gets the **9998 string thrown by codegen** or a string rethrown by WASM, **not an envelope** (see §2.5) | + +> In one sentence: **the GET failure WASM returns to Dart is an envelope, but this envelope is dismantled by layer 5, so the GET failure the service gets is a string.** The §2.0.0 table and §2.5 description both describe the format the service gets (GET=string). + +> For reading a structured envelope see §2.3: **`success:false` ≠ firmware processed it successfully**. Only the errorCode tells you whether the request reached firmware. + +### 2.1 Unified format of the thrown string (three-level nesting) + +Yes, the thrown-string format is **completely unified**, assembled level by level via `write!` by Rust's `Display` impl, with a fixed three-level structure: + +``` +"{operation prefix}{category}: {detail}" + └─ §2.1 table └─ §2.2 one of five └─ actual error message (may further contain (code: XXXX)) +``` + +Assembly source (Rust `error.rs`'s `Display` impl): the outer `UspError::Display` writes `"{category} error: {sub error}"`, then each WASM method prepends the operation prefix in front of it. Example breakdown: + +``` +"Set failed: Protocol error: Decoding error: Received error response: ... (code: 7026)" + ─────┬──── ──────┬─────── ───────────────────────┬────────────────────────────── + op prefix category(protocol) detail (contains passed-through firmware code 7026) + +"Login failed: Authentication error: Invalid credentials" + ──────┬───── ─────────┬─────────── ────────┬───────── + op prefix category(auth) detail + +"Get failed: Validation error: Required fields missing from response: DestIP (code: 9998)" + ─────┬──── ───────┬───────── ──────────────────┬───────────────────────────────────── + op prefix category(validation) detail (the missing-field error thrown by Dart codegen, code 9998) +``` + +The Dart-side `parseUspError` (`usp_error.dart`) relies on this fixed structure to break it apart: +- `_opPrefix = ^(\w+) failed: (.+)$` splits out the operation and the rest +- `_parseCategoryAndMessage` matches the 5 category prefixes (§2.2) +- `_faultCode = \(code:\s*(\d+)\)` grabs the fault code from the end of detail +- `_httpStatus = HTTP error: HTTP (\d+)` grabs the HTTP status code + +⚠ Exception: a few strings that don't match `"{X} failed:"` (e.g. WS's `"UspWsClient is closed"`, SSE's `StateError`) can't be parsed → `parseUspError` returns null → mapped to `UnexpectedError`. + +### 2.1.1 Thrown-string prefixes + +Dart error mapping **only needs to handle these 7** (main line, the everyday USP request path): + +(all in each method's `Err(e) => Err(JsValue::from_str(...))` in `wasm/mod.rs`) + +| Operation | prefix | +|---|---| +| constructor | `"Failed to create client: "` | +| login | `"Login failed: "` | +| logout | `"Logout failed: "` | +| refreshToken | `"Token refresh failed: "` | +| subscribe | `"Subscribe failed: "` | +| unsubscribe | `"Unsubscribe failed: "` | +| listSubscriptions | `"List subscriptions failed: "` | + +**There are 7 more WebSocket strings that can be ignored** (`"encode Msg failed: "`, `"wrap Record failed: "`, `"unwrap Record failed: "`, `"decode Msg failed: "`, `"encode Operate failed: "`, `"build WSConnect failed: "`, `"UspWsClient is closed"`). Reasons: +- They belong to **side branch 2 (WebSocket firmware upload)**, not on the everyday GET/SET/notification path. +- The whole section is wrapped by `#[cfg(feature = "websocket")]` (the `ws_binding` module in `wasm/mod.rs`), and `Cargo.toml`'s `default = ["native"]` **does not include the websocket feature** — unless build explicitly passes `--features websocket`, these 7 functions along with their error strings **are not compiled into the WASM binary at all**. +- Conclusion: `mapUspErrorToServiceError` does not need to handle them. + +### 2.2 category (5 kinds) + nested sub-strings (error.rs; this is the string that follows the prefix, and also appears inside the 9999 errorMessage) + +| category prefix | sub-variant Display string (detail) | +|---|---| +| `Transport error: ` | `Network error: {m}`/`HTTP error: {m}` (m can be `HTTP 500`/`HTTP 404`/`WASM not yet implemented`)/`Request timeout`/`Connection refused`/`TLS error: {m}`/`Invalid URL: {m}`/`Response correlation timeout for msg_id: {id}` | +| `Protocol error: ` | `Encoding error: {m}`/`Decoding error: {m}` (contains `Received error response: {msg} (code: {code})`)/`Malformed message: {m}`/`Unsupported version: {m}`/`Missing field: {m}` | +| `Authentication error: ` | `Invalid credentials`/`Session expired`/`Invalid token: {m}` (`No token in response`/`No token in refresh response`)/`Permission denied`/`Authentication required` | +| `Operation error: ` | `Get/Set/Add/Delete failed for '{path}': {reason}`/`Operate failed for '{command}': {reason}`/`Path not found: {path}`/`Parameter is read-only: {path}`/`Invalid value '{value}' for '{path}': {reason}` ⚠ this group is only constructed in Rust **native ffi** (`ffi/mod.rs`, `#[cfg(not(target_arch="wasm32"))]`), **not included in the WASM build → unreachable in production**. The corresponding `_mapOperationError` is dead code (see the comment on that function in `usp_error.dart`) | +| `Validation error: ` | client-side: `Delete paths cannot be empty`/`Invalid JSON: ...`/`... not yet implemented` etc. | + +There are also 3 input-validation strings packed directly into 9999: `"Invalid path format: all paths must be strings"`, `"Invalid input: paths must be string or array of strings"`, `"Invalid input: items must be object or array of objects with {path, params}"`. + +### 2.3 Numeric error codes + +> ⚠ **fault codes come from two source categories**, and **only the client-side 9999 can be exhaustively enumerated from Rust**; +> firmware fault codes (7xxx / 9xxx) are the **open set on the router-side bbfdm**, which the Rust client only **passes through, does not enumerate, does not hard-code**. +> Therefore "exhaustively enumerate all error codes from the usp-client source" **does not hold** for passed-through codes — they are not in the Rust source. +> The real fault-code list is on the firmware (bbfdm/OBUSPA) side; you need to request the vendor fault-code table from the firmware team. + +| code | When | Source | Handling for Dart | +|---|---|---|---| +| **9999** | any transport/auth/protocol/validation failure (including input validation). message = `"Transport error: " + UspError Display` | **generated by the Rust client itself** (`wasm/mod.rs build_transport_error_unified`, the only hard-coded code) | parse the category/detail inside message, then map | +| **7xxx** (TR-369 standard range) | per-path/per-param failure returned by firmware. message = firmware's original text | **passed through by firmware** (copied verbatim, Rust does not synthesize) | semanticize: 7004/7005/7006→InvalidInput, 7026/7027→ResourceNotFound; the rest go to agent error | +| **9xxx** (bbfdm vendor range) | SET/GET rejected by the bbfdm backend (e.g. `9001` rejected, `9005` unimplemented parameter, `9008` read-only) | **passed through by firmware(bbfdm)** | 9001→Unauthorized, 9005/9007→ResourceNotFound, 9008→InvalidInput | +| **9998** | GET response is **missing a required field** (router did not return some param). **Not emitted by Rust, thrown by Dart codegen**: `'Get failed: Validation error: Required fields missing from response: ... (code: 9998)'` (34 `lib/generated/*.g.dart`) | **Dart codegen** | goes through `category=validation` → `InvalidInputError` | +| 9997 | — | none of Rust / lib / test have this code | does not exist, no need to handle | +| 0 | success sentinel, does not appear in the error block | — | — | + +> Evidence (9xxx really is router behavior, not dead code): `usp_test_console_view.dart` marks "expect fault 9005/9008"; +> `usp_ipv4_section.dart` + `usp_internet_settings_service.dart` comment "bbfdm rejects SET (fault 9001)"; +> `test/core/usp/errors/usp_error_test.dart` has verified the 9001/9005/9008 mappings. + +#### 9999 vs 7xxx/9xxx —— essential difference: **whether the request reached firmware** + +This is the core of reading a structured envelope. Both look like `{success:false, error:{path:{errorCode, errorMessage}}}`, but their meanings are opposite: + +| | **9999** | **7xxx / 9xxx** | +|---|---|---| +| Who produces it | **the Rust client itself** (`build_transport_error_unified`, the only hard-coded code) | **firmware** (OBUSPA/bbfdm), Rust only passes through | +| From which branch | the `Err(e)` branch of `set/get/...` | the `Ok(response)` branch, reading `err_code` from the successfully decoded response (`serialize_*_response_to_js` in `wasm/mod.rs`) | +| Did the request reach firmware | **no** — it failed on the client side, never (successfully) connected to the router | **yes** — firmware received it, processed it, and proactively rejected it | +| Underlying errors it covers | **one code packs five categories**: ① transport (timeout / connection refused / HTTP 5xx / TLS) ② auth (401 / session expired) ③ protocol (protobuf encode/decode failure, correlation timeout) ④ client validation (path format wrong) ⑤ wrong input shape | a single clear semantic: parameter read-only / value invalid / path not found / bbfdm rejected… | +| errorMessage | `"Transport error: " + UspError Display` (all detail hidden inside the string, must be parsed again) | firmware's original text | +| Meaning for the UI | "**not sent out**, please check connection / re-login / retry" | "**sent but rejected**, please change your input" | + +**In one sentence**: +- `errorCode == 9999` → this is not firmware's fault; the problem is **between the client and the router** (network, authentication, encoding). The same 9999 covers **five major categories** — timeout, 401, TLS, protobuf error, etc. — and you only learn the real type by further parsing the `"Transport error: {category}: ..."` inside `errorMessage`. +- `errorCode` is 7xxx or 9xxx → the request **did reach firmware and was processed by it**; firmware proactively returned this fault code (7xxx=TR-369 standard, 9xxx=bbfdm vendor). The code itself is clear semantics; just map it directly. + +> Corollary: seeing `success:false` **cannot** be taken to mean "firmware processed it successfully". **Only `success:true` (full success or partial) guarantees firmware processed it.** +> For `success:false` you must first look at the code: 9999=did not reach firmware; 7xxx/9xxx=reached and was rejected. + +#### "Information completeness" of each source: are code and message always present? + +What each source stuffs in when building the error determines whether Dart can trust that code / message always exists: + +| Source | errorCode | errorMessage | Note | +|---|---|---|---| +| lifecycle string (login…) | ⚠ **not guaranteed** | ✅ always present | string = prefix + category + detail; only when the underlying is protocol (passed-through firmware) does detail carry `(code:)`. **auth-category errors have no code to begin with** → `parseUspError` can't grab it → faultCode=null | +| 9999 envelope | ✅ always 9999 | ✅ always present | `errorMessage` is at least `"Transport error: ..."`, but may be short (e.g. `Request timeout`) | +| firmware passed-through envelope (7xxx/9xxx) | ✅ field always present | ⚠ **field always present, value may be empty string** | `serialize_*_response_to_js` unconditionally stuffs both fields; but `err_msg` is a protobuf `string`, and when firmware doesn't fill it, it is `""` (not null) | +| codegen 9998 string | ✅ always 9998 | ✅ always present | the message contains the list of missing fields, and is only thrown when `missing.isNotEmpty` | + +**Dart model-layer fallback** (`UspErrorDetail.fromMap`): `errorCode` missing→`-1`, `errorMessage` missing/null→`'Unknown error'`. So by the Dart model layer, **both are always non-null**. + +> In one sentence: the **fields** of the envelope categories (9999/firmware/9998) are all guaranteed to exist (the Dart layer adds `-1`/`'Unknown error'` as a further fallback); the only two not guaranteed are — **lifecycle strings often lack a code** (auth errors have no code), and **the firmware message value may be an empty string** (the field is still there). + +### 2.4 Dart mapping rules (`mapUspErrorToServiceError` in `usp_error.dart`) cross-reference + +The existing fault-code map (`_mapProtocolError` in `usp_error.dart`): +`7004/7005/7006→InvalidInput`, `7026/7027→ResourceNotFound`, `9001→Unauthorized`, `9005/9007→ResourceNotFound`, `9008→InvalidInput`. +At this point the semantics are already aligned with `UspErrorDetail` (`usp_operation_result.dart`, which recognizes 7004/7005/7006/7026/7027). + +> **Diagnostic fields**: when each `_mapXxx` produces a ServiceError it carries `code` (faultCode / httpStatus) + `detail` (raw message). Even when the type information is coarser (e.g. `ResourceNotFoundError`), the underlying code/detail is still on the ServiceError for log and View use. + +Behavioral characteristics and limitations: +1. **Semanticization of the envelope path is consumed in the View**: batch SET/ADD/DELETE failures go through `UspResultParser` → service throws `Usp{Partial,Complete}FailureError`. + - These two types store `List failures` (full path+code+message), so fault codes are not lost; helpers like `UspErrorDetail`'s `isObjectNotFound`/`isInvalidParameterValue` are available. + - Path 2 only produces the two **container types** `UspPartial/CompleteFailureError`; unlike path 1, it does not subdivide into `ResourceNotFoundError`/`InvalidInputError` by code. This is **by design** — a batch may have multiple entries each with a different code, which cannot be stuffed into a single semantic type. Semanticization happens in the **View**: iterate over `failures`, judge the code with a helper per entry to decide what to display (for how, see [implementation guide](error-handling-implementation-guide.md) §4.2). +2. **9999 has no dedicated mapping**: it is the most common client-side code, but the mapping only looks at the category string inside message, not at code 9999 itself (it currently works fine, because the string is sufficient). +3. **WS / SSE `StateError`** (e.g. `'WebSocket connection timeout'`) does not match the `"{Op} failed:"` format, so by the time it reaches `mapUspErrorToServiceError` it just becomes a generic `UnexpectedError`. +4. **The string contract is fragile** (the "Error Contract" self-comment table at the top of `usp_error.dart`): the transport/auth/protocol mappings rely on substring/regex against Rust strings; **the moment the Rust-side string changes, it silently fails**. §2.1/§2.2 are the complete client-side output contract of the current Rust, and can be turned into a contract test to pin it down (note: the passed-through 7xxx/9xxx are not in this contract; they are decided by firmware). + +### 2.5 ⚠⚠ Major defect: a GET-failure 9999 is disguised as 9998, a network error misjudged as an input error + +This is the entry with **the most severe semantic misplacement** in the error model, and is an **unfixed bug**. + +**Symmetry break**: SET/ADD/DELETE preserve the envelope → go through `UspResultParser.parseSetResult` → semantically correct; +but **GET dismantles the envelope already at layer 5 (`UspClientWeb.get` in `usp_client_wasm.dart`), takes only `result.data`, and discards `success`/`error`**. +Therefore the corresponding `UspResultParser.parseGetResult` (`usp_operation_result.dart`) is **dead code, called by no one** (only a definition + a unit test exist). + +**Consequence — the actual flow of a 9999 GET failure**: +``` +WASM returns {success:false, error:{path:{errorCode:9999}}} ← network down / did not reach firmware + ↓ UspClientWeb.get takes only data (success/error discarded) +data = {} ← the failure envelope has no data → empty map + ↓ codegen _fromResponse checks required fields (dmz.g.dart) +all required fields missing → throw 'Get failed: Validation error: ... (code: 9998)' + ↓ service catch → mapUspErrorToServiceError +→ category=validation → InvalidInputError +``` +**A 9999 (cannot connect to router, should be `NetworkError` "please retry") is silently turned into 9998 (missing field) → `InvalidInputError` ("there's a problem with your input").** The error semantics are completely misplaced, and the user receives a misleading message. + +**The only case that is not affected**: if WASM's GET is a whole Promise **reject** (throws a string instead of returning an envelope), `UspClientWeb.get`'s `catch → rethrow` lets the string bubble up as-is and be mapped correctly. But §2.0 has already confirmed that a GET failure mostly goes through `build_transport_error_unified` returning an envelope → affected. + +**Cross-reference table**: + +| | Design intent | Actual code | +|---|---|---| +| GET failure envelope | hand it to `parseGetResult` to judge success/error | the envelope is dismantled at layer 5, only data left | +| 9999 GET failure | → `NetworkError` (cannot connect) | disguised as 9998 → `InvalidInputError` (input wrong) | +| `parseGetResult` | the parser for the GET path | **dead code, called by no one** | + +**Fix directions** (not done, for future evaluation): +- **Direction A (treats the symptom, small)**: when `success==false`, instead of silently returning empty data, `UspClientWeb.get` should throw a string carrying 9999 → correctly mapped to `NetworkError`. Small blast radius. +- **Direction B (treats the root cause, large)**: have GET also preserve the envelope and go through `parseGetResult`, symmetric with SET. But this requires changing the return type of all codegen `fetch`; large blast radius. + +> This echoes the last ⚠ item of §1 "GET's special transform" (GET discards the envelope, and a failure only surfaces when WASM itself throws a string) — this section is the full consequence and root cause of that note. + +### 2.6 Convergence chart: two paths → ServiceError → View + +All errors finally converge into a `ServiceError` in the **service's `catch (e)`**. The real basis for the split is **fetch (GET) vs write (SET/ADD/DELETE/operate)** — exactly corresponding to §3's fetch/save pattern. + +> ⚠ Not in this chart: **lifecycle errors** (login/logout/refreshToken failures) are digested internally by `UspAuthCoordinator`; the **raw string never becomes a `ServiceError` and never flows into this chart**. But the app still learns the result in the form of "authentication state", just not as an error object: +> - `syncAfterLocalLogin`: after catch only logs and swallows (local login succeeds as usual, USP failure has no impact, falls back to JNAP) +> - `tryUspLogin` / `restoreSession`: catch → `return false` (the app gets a boolean, not a string) +> - `ensureAuth` (refreshToken): 401 → triggers the `onForceLogout` callback (forced logout) +> +> Design intent: USP is a second authentication channel attached alongside local login; on failure it degrades into an "is it usable" state, and should not make the app blow up with a `ServiceError`. + +``` + Path 1: fetch (GET) Path 2: write (SET/ADD/DELETE/operate) + receives a "string" receives an "envelope" {success,result:{data,error?}} + ┌────────────────────────────┐ ┌────────────────────────────────────┐ + │ GET-failure string, two sources:│ │ the envelope's error contains a code:│ + │ • codegen missing required → 9998│ │ • 9999 (WASM didn't reach firmware)│ + │ • string WASM get rethrows itself│ │ • 7xxx/9xxx (firmware passthru, reject)│ + └────────────────────────────┘ └────────────────────────────────────┘ + │ │ + │ UspResultParser.parseSetResult/Add/Delete + │ → _parseGenericResult + │ │ + │ ┌──────────────────┼──────────────────┐ + │ ▼ ▼ ▼ + │ success & no error success & has error success=false + │ │ │ │ + │ ▼ ▼ ▼ + │ UspSuccess UspPartialSuccess UspFailure + │ (no throw) │ │ + │ ▼ ▼ + │ service switch → throw + │ UspPartialFailureError / UspCompleteFailureError + │ (which is itself a ServiceError) + │ │ + ▼ ▼ + ╔═══════════════════════════════════════════════════════════════╗ + ║ service's catch (e) { ... } ← the two paths converge here ║ + ║ if (e is ServiceError) rethrow; // path 2 self-thrown passes ║ + ║ else throw mapUspErrorToServiceError(e); // only path 1 strings map ║ + ║ └ parseUspError splits string → UspError{category,faultCode} ║ + ║ → NetworkError / InvalidInputError / ResourceNotFound / ║ + ║ Unauthorized / UnexpectedError ║ + ╚═══════════════════════════════════════════════════════════════╝ + │ + ▼ throw ServiceError + ┌──────────────┐ + │ provider │ save : rethrow + │ │ fetch: state.error = e (type passed through, not flattened to string) + └──────────────┘ + │ + ▼ ServiceError (type preserved) + ┌──────────────┐ + │ View │ localizeServiceError(ctx, e) → localized message + └──────────────┘ +``` + +**Two-path cross-reference**: + +| | Path 1: fetch (GET) | Path 2: write (SET/ADD/DELETE/operate) | +|---|---|---| +| Form the service receives | **string** (the envelope has already been dismantled by layer 5 `UspClientWeb.get`, only data left) | **envelope** `{success,result:{data,error?}}` | +| Source of string/code on failure | codegen throws 9998 for missing fields, or a string rethrown by WASM | `errorCode` inside error: 9999 (WASM) or 7xxx/9xxx (firmware) | +| Gate method | `mapUspErrorToServiceError` → `parseUspError` | `UspResultParser.parseXxxResult` → `_parseGenericResult` | +| Intermediate type | `UspError` (temporary, purely for splitting the string) | `UspSuccess` / `UspPartialSuccess` / `UspFailure` + `UspErrorDetail` | +| ServiceError produced | `NetworkError` / `InvalidInputError` / `ResourceNotFoundError` / `Unauthorized` / `UnexpectedError` | `UspPartialFailureError` / `UspCompleteFailureError` | +| How they converge | service catch receives a string → `mapUspErrorToServiceError` | service itself `throw Usp*FailureError` (already a ServiceError) → `if (e is ServiceError) rethrow` lets it pass, **does not map again** | + +**Split decision point** (`_parseGenericResult`, path 2 only): +- `success && error==null` → `UspSuccess` (no throw, normal return) +- `success && error!=null` → `UspPartialSuccess` → service converts to `UspPartialFailureError` +- `success==false` → `UspFailure` → service converts to `UspCompleteFailureError` + +> ⚠ Note 9999's classification: it is produced by WASM and represents "did not reach firmware" (§2.3), but **in form it is an errorCode inside an envelope**, so it goes through **path 2** (parser), not path 1. "Who produces it" and "which path it goes" are two different things. + +> In one sentence: **strings go through `mapUspErrorToServiceError`, envelopes go through `UspResultParser`, and both converge into a `ServiceError` in the service `catch`**; afterward the provider passes the type through (save rethrow / fetch stores `state.error`), and the view localizes with `localizeServiceError` — for that see §3 and the [implementation guide](error-handling-implementation-guide.md). + +--- + +# 3. The Rationale Behind the Error Handling Pattern (Service-layer mechanism) + +> This section explains **why** the Service layer's fetch and save are written differently (fetch has no guard, save has a guard) — this is the most easily misunderstood core mechanism of the whole pipeline, and one that does not change over time. +> The **current practice** of the Provider / View layers (how the type is passed through, how it is localized for display) is in the [implementation guide](error-handling-implementation-guide.md); the end of this section only explains the pain points before PR #953, as background for "why that refactor was done". + +The Service layer's error convergence (both fetch and save turn errors into `ServiceError`, relying on `mapUspErrorToServiceError` + `UspResultParser`) preserves the typed `ServiceError` and carries `code`/`detail` (path 1) or a `failures` list (path 2) diagnostic info. + +### Service-layer detail: fetch and save are two different patterns —— the difference is "whether it self-throws a ServiceError" + +The only structural difference between the two is **the `if (e is ServiceError) rethrow` guard**. To understand it, look at "whether anyone throws a ServiceError before the catch": + +**fetch pattern (GET, no guard)** — e.g. `usp_dmz_service.fetch`: +```dart +try { + final data = await Dmz.fetch(_usp); // codegen / WASM failure → always throws a "raw string" + return _toModel(data); // pure data assembly, does not throw ServiceError +} catch (e) { + throw mapUspErrorToServiceError(e); // map directly, no guard needed +} +``` +**Why fetch needs no guard**: inside fetch's try block, the only thing that can be thrown is codegen / WASM's **raw string** (e.g. `'Get failed: ... (code: 9998)'`), plus pure data assembly (which does not throw ServiceError). **The `e` the catch receives can never be a ServiceError**, so `if (e is ServiceError)` is always false; adding it is useless → standard fetch never adds it. + +**save pattern (SET/ADD/DELETE, with guard)** — e.g. `usp_dmz_service.add` / `.update`: +```dart +try { + final result = await Dmz.update(...); + switch (UspResultParser.parseSetResult(result)) { + case UspSuccess(): break; + case UspPartialSuccess(...): throw UspPartialFailureError(...); // ← service itself throws a ServiceError + case UspFailure(...): throw UspCompleteFailureError(...); // ← same as above + } +} catch (e) { + if (e is ServiceError) rethrow; // guard: don't re-wrap the Usp*FailureError I just threw + throw mapUspErrorToServiceError(e); // only the remaining raw strings get mapped +} +``` +**Why save needs the guard**: save first parses the batch envelope and **proactively `throw UspPartialFailureError` / `UspCompleteFailureError` (these are already ServiceError)**. Without the guard, these self-thrown ServiceErrors would be caught by the outer catch and thrown into `mapUspErrorToServiceError` again, and because they don't match the `"{Op} failed:"` format, they would be mis-wrapped into `UnexpectedError`, losing all semantics. The guard is what lets "the ServiceError I threw myself bubble up as-is". + +> **In one sentence**: the guard = an indicator of "whether the try block self-throws a ServiceError". Yes (save) → needs a guard; no (fetch) → does not. + +The `is ServiceError` guard **only appears in write/operate methods** (update/save/add/delete/enable/disable/ping/traceRoute…); fetch never has it. This is the rule, no exceptions. + +- ⚠ **Exceptions that do not go through this mapping**: the `apps` service throws a raw `Exception` (`usp_apps_service.dart`, because it is lighttpd static JSON, not USP); `_shared`'s polling / PDF service deliberately swallows (with comments explaining). These do not conform to the ServiceError contract above. + +### Provider-layer fixed mechanism +- the framework `save()` (`preservable_notifier_mixin.dart`) is **transparent and does not catch**, letting the `ServiceError` pass straight through to the View. The framework does not import `ServiceError` at all. This is the underlying reason the save path's "Provider only rethrows, View catches with try/catch" can hold. + +### Pain points before PR #953 (why that refactor was done) + +Before the refactor, the Service layer already produced a typed `ServiceError`, but this type was discarded in the upper layers, so the UI could not localize by type: + +- **Provider fetch** flattened the `ServiceError` into `status.errorMessage = '$e'` (the type was lost here), and each notifier hand-wrote a copy, with no shared helper. +- **View** had no `ServiceError → message` mapper at all; `showFailedSnackBar(ctx, 'Failed to save: $e')` was repeated verbatim across ~10 views, and error messages were barely localized (only 1 of the save error messages was localized). + +PR #953 wired this line through: the Provider passes the type through (`ServiceError? error`, no longer `'$e'`), added a central mapper `localizeServiceError()` (`lib/components/localizations/service_error_localizations.dart`) + a shared empty-state widget `ServiceErrorView`, and the View always goes through them to localize. **For how to write it see the [implementation guide](error-handling-implementation-guide.md).** + +### Things not yet done + +- **contract test (TODO)**: use §2.1/§2.2 to pin down the Rust client-side string contract (the transport/auth/protocol mappings rely on substring/regex against Rust strings, and the moment the Rust-side string changes it silently fails). The passed-through 7xxx/9xxx are not in this contract; they are decided by firmware, and you need to request the vendor fault-code table from the firmware team separately. +- **§2.5 GET 9999→9998 bug (known, not yet fixed)**: see that section. + +> **This doc is "background knowledge / full-chain reference" (answering "why").** The actual "how to implement error handling following the existing pattern" (the Service/Provider/View three-layer way of writing, what to display, things to note, checklist) is in the [implementation guide](error-handling-implementation-guide.md). +> The §2.5 GET 9999→9998 bug is still unfixed — this is a known limitation when implementing localization (a GET connection failure will be localized as "input error"), also noted in the implementation guide §7. + +--- + +## File Index + +**Dart**: `usp_client.dart`(facade), `web/usp_client_wasm.dart`(WASM boundary), `errors/usp_error.dart`(mapping), `errors/service_error.dart`(types), `models/usp_operation_result.dart`(envelope parsing), `providers/usp_mutation_lock.dart`, `bridge_request_throttler.dart`, `framework/preservable_notifier_mixin.dart`, `web/usp_init.js`+`usp_client.js`. +**Rust** (`usp-client/`): `src/wasm/mod.rs`(boundary+serializer), `src/client.rs`(orchestration), `src/error.rs`(**all error strings**), `src/protocol/{encode,decode}.rs`, `src/transport/http.rs`, `proto/usp.proto`, `doc/wasm-api-reference.md`(partial drift). diff --git a/doc/usp/vendored-artifacts.md b/doc/usp/vendored-artifacts.md index d327f91b9..7efce7f6b 100644 --- a/doc/usp/vendored-artifacts.md +++ b/doc/usp/vendored-artifacts.md @@ -4,18 +4,18 @@ Files in this repo that are built or generated from `linksys/usp_framework` and ## Upstream -- **Local path**: `/Users/hankyu/linksys/usp/usp_framework/` +- **Local path**: `linksys/usp/usp_framework/` - **Repo**: `github.com/linksys/usp_framework` ## Manifest -**Last updated**: 2026-06-18 +**Last updated**: 2026-07-07 | # | Artifact | Version | Checked-in path | Upstream source | |---|----------|---------|-----------------|-----------------| -| 1 | `usp-codegen` (Mach-O arm64) | **0.12.5** | `tools/usp-codegen` | `usp-codegen/bin/usp-codegen` (built from `src/` via `Makefile.standalone`) | -| 2 | `usp_client.js` | **0.11.1** | `web/usp_client.js` | `usp-client/pkg/usp_client.js` | -| 3 | `usp_client_bg.wasm` | **0.11.1** | `web/usp_client_bg.wasm` | `usp-client/pkg/usp_client_bg.wasm` | +| 1 | `usp-codegen` (Mach-O arm64) | **0.15.3** | `tools/usp-codegen` | `usp-codegen/bin/usp-codegen` (built from `src/` via `Makefile.standalone`) | +| 2 | `usp_client.js` | **0.12.0** | `web/usp_client.js` | `usp-client/pkg/usp_client.js` | +| 3 | `usp_client_bg.wasm` | **0.12.0** | `web/usp_client_bg.wasm` | `usp-client/pkg/usp_client_bg.wasm` | ## Derived (generated locally, not copied) @@ -29,17 +29,17 @@ The YAML definitions are **not vendored** into this repo. 1. **Codegen binary** ```bash - cd /Users/hankyu/linksys/usp/usp_framework/usp-codegen + cd linksys/usp/usp_framework/usp-codegen make -f Makefile.standalone clean all - cp bin/usp-codegen /Users/hankyu/linksys/PrivacyGUI/tools/usp-codegen - /Users/hankyu/linksys/PrivacyGUI/tools/usp-codegen --version # verify + cp bin/usp-codegen linksys/PrivacyGUI/tools/usp-codegen + linksys/PrivacyGUI/tools/usp-codegen --version # verify ``` 2. **Regenerate `.g.dart`** (bypasses the known `--local` bug in `tools/usp-codegen.sh`, see `doc/usp/issues/usp-codegen-script-issue.md`) ```bash - cd /Users/hankyu/linksys/PrivacyGUI + cd linksys/PrivacyGUI ./tools/usp-codegen \ - --definitions-dir /Users/hankyu/linksys/usp/usp_framework/usp-definitions \ + --definitions-dir linksys/usp/usp_framework/usp-definitions \ --output-dir lib/generated \ --language dart \ --client-import 'package:privacy_gui/core/usp/services/usp_client.dart' \ @@ -49,8 +49,8 @@ The YAML definitions are **not vendored** into this repo. 3. **Web client assets** ```bash - cp /Users/hankyu/linksys/usp/usp_framework/usp-client/pkg/usp_client.js web/usp_client.js - cp /Users/hankyu/linksys/usp/usp_framework/usp-client/pkg/usp_client_bg.wasm web/usp_client_bg.wasm + cp linksys/usp/usp_framework/usp-client/pkg/usp_client.js web/usp_client.js + cp linksys/usp/usp_framework/usp-client/pkg/usp_client_bg.wasm web/usp_client_bg.wasm ``` 4. **Update the version table above** in the same commit as the artifact change. diff --git a/lib/ai/providers/usp_command_provider.dart b/lib/ai/providers/usp_command_provider.dart index d11f26e77..afd089962 100644 --- a/lib/ai/providers/usp_command_provider.dart +++ b/lib/ai/providers/usp_command_provider.dart @@ -364,16 +364,15 @@ class UspCommandProvider implements IRouterCommandProvider { ' - ${d['name']} (${d['ip']}) ${d['connectionType']} signal=${d['signalStrength']}'); } - // Mesh extenders (non-master nodes) - final extenders = data.nodeModels - .where((n) => !n.isMaster) + // Mesh extenders (slave nodes) + final extenders = data.slaves .map((node) => { 'name': node.displayName, 'mac': node.deviceId, 'model': node.model, - 'backhaulMediaType': node.backhaulMediaType, - 'backhaulSignalStrength': node.backhaulSignalStrength, - 'backhaulUplinkRate': node.backhaulUplinkRate, + 'backhaulMediaType': node.backhaul.mediaType, + 'backhaulSignalStrength': node.backhaul.signalStrength, + 'backhaulUplinkRate': node.backhaul.uplinkRate, }) .toList(); @@ -504,7 +503,8 @@ class UspCommandProvider implements IRouterCommandProvider { 'mac': c.mac, 'ip': c.ip, 'hostName': c.hostName, - 'active': c.active, + 'leaseActive': c.leaseActive, + 'isOnline': c.isOnline ?? false, 'leaseExpiry': c.leaseExpiryFormatted, 'leaseRemaining': c.leaseTimeFormatted, }) @@ -869,16 +869,15 @@ String buildRouterContext(ProviderReader read) { buffer.writeln('- Currently online: ${devices.onlineClientCount}'); buffer.writeln(); - // Mesh nodes (extenders) - final nodeModels = devices.nodeModels; - final extenders = nodeModels.where((n) => !n.isMaster).toList(); + // Mesh nodes (slave extenders) + final extenders = devices.slaves; if (extenders.isNotEmpty) { _log('buildRouterContext: extenders=${extenders.length}'); buffer.writeln('## Mesh Extenders'); for (final ext in extenders) { _log('buildRouterContext: - ${ext.displayName} (${ext.deviceId})'); buffer.writeln( - '- ${ext.displayName}: ${ext.model}, backhaul=${ext.backhaulMediaType}, rssi=${ext.backhaulSignalStrength ?? "N/A"}'); + '- ${ext.displayName}: ${ext.model}, backhaul=${ext.backhaul.mediaType}, rssi=${ext.backhaul.signalStrength ?? "N/A"}'); } buffer.writeln(); } diff --git a/lib/app.dart b/lib/app.dart index ed71e533d..c9eac462e 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -10,6 +10,7 @@ import 'package:privacy_gui/core/utils/logger.dart'; import 'package:privacy_gui/demo/providers/demo_theme_config_provider.dart'; import 'package:privacy_gui/demo/theme_studio/demo_theme_builder.dart'; import 'package:privacy_gui/theme/theme_json_config.dart'; +import 'package:privacy_gui/localization/fallback_font_resolver.dart'; import 'package:privacy_gui/localization/localization_hook.dart'; import 'package:privacy_gui/components/layouts/root_container.dart'; import 'package:privacy_gui/providers/app_settings/app_settings.dart'; @@ -96,7 +97,7 @@ class _LinksysAppState extends ConsumerState /// and a responsive layout container. @override Widget build(BuildContext context) { - logger.d('[App]: build: $_currentRoute'); + logger.t('[App]: build: $_currentRoute'); final appSettings = ref.watch(appSettingsProvider); final systemLocaleStr = Intl.getCurrentLocale(); @@ -160,19 +161,39 @@ class _LinksysAppState extends ConsumerState required DemoThemeConfig demoConfig, required Color? userThemeColor, }) { - final appLightTheme = buildDemoThemeData( + var appLightTheme = buildDemoThemeData( brightness: Brightness.light, config: demoConfig, themeConfig: themeConfig, userThemeColor: userThemeColor, ); - final appDarkTheme = buildDemoThemeData( + var appDarkTheme = buildDemoThemeData( brightness: Brightness.dark, config: demoConfig, themeConfig: themeConfig, userThemeColor: userThemeColor, ); + // CJK / non-Latin fallback for the active locale. The subset fonts are + // eager-loaded via pubspec `fonts:` (registered before first frame). Adding + // the fallback family to ThemeData.textTheme covers raw `Text` / third-party + // widgets; ui_kit's AppText.resolve() injects the same family per-locale for + // AppText. Without the family in the TextStyle, the engine treats CJK code + // points as missing and probes the CDN. Null for Latin-covered locales. + final effectiveLocale = appSettings.locale ?? systemLocale; + final cjkFallback = + FallbackFontResolver.prefixedFallbackFor(effectiveLocale); + if (cjkFallback != null) { + appLightTheme = appLightTheme.copyWith( + textTheme: + appLightTheme.textTheme.apply(fontFamilyFallback: cjkFallback), + ); + appDarkTheme = appDarkTheme.copyWith( + textTheme: + appDarkTheme.textTheme.apply(fontFamilyFallback: cjkFallback), + ); + } + return MaterialApp.router( onGenerateTitle: (context) => loc(context).appTitle, theme: appLightTheme, @@ -233,8 +254,7 @@ class _LinksysAppState extends ConsumerState } void _tryResumeSse() { - final loginType = ref.read(authProvider).value?.loginType; - if (loginType == null || loginType == LoginType.none) return; + if (!(ref.read(authProvider).value?.isLoggedIn ?? false)) return; final sseManager = ref.read(sseManagerProvider); if (sseManager == null) return; final sseState = sseManager.connection.connectionState.value; @@ -252,7 +272,7 @@ class _LinksysAppState extends ConsumerState /// native splash screen, revealing the app's UI. _initAuth() { ref.read(authProvider.notifier).init().then((_) { - logger.d('[App]: init auth finish'); + logger.t('[App]: init auth finish'); FlutterNativeSplash.remove(); }); } diff --git a/lib/components/layouts/root_container.dart b/lib/components/layouts/root_container.dart index 44b21a893..352b307fa 100644 --- a/lib/components/layouts/root_container.dart +++ b/lib/components/layouts/root_container.dart @@ -34,7 +34,7 @@ class _AppRootContainerState extends ConsumerState { @override Widget build(BuildContext context) { - logger.d('[App]: Root Container build: ${widget.route}'); + logger.t('[App]: Root Container build: ${widget.route}'); return LayoutBuilder(builder: ((context, constraints) { return IdleChecker( @@ -45,7 +45,7 @@ class _AppRootContainerState extends ConsumerState { return; } // not log in yet - if (ref.read(authProvider).value?.loginType == LoginType.none) { + if (!(ref.read(authProvider).value?.isLoggedIn ?? false)) { return; } // not go into dashboard yet @@ -61,7 +61,7 @@ class _AppRootContainerState extends ConsumerState { if (ref.read(idleCheckerPauseProvider) == true) { return; } - logger.d('[App]: Idled!'); + logger.t('[App]: Idled!'); ref.read(authProvider.notifier).logout(); }, child: Container( diff --git a/lib/components/styled/general_settings_widget/general_settings_widget.dart b/lib/components/styled/general_settings_widget/general_settings_widget.dart index ce9010025..3270badcf 100644 --- a/lib/components/styled/general_settings_widget/general_settings_widget.dart +++ b/lib/components/styled/general_settings_widget/general_settings_widget.dart @@ -25,9 +25,8 @@ class GeneralSettingsWidget extends ConsumerStatefulWidget { class _GeneralSettingsWidgetState extends ConsumerState { @override Widget build(BuildContext context) { - final loginType = - ref.watch(authProvider.select((state) => state.value?.loginType)) ?? - LoginType.none; + final isLoggedIn = ref.watch( + authProvider.select((state) => state.value?.isLoggedIn ?? false)); // Watch Theme.of(context) to trigger rebuild when global theme changes Theme.of(context); @@ -110,8 +109,7 @@ class _GeneralSettingsWidgetState extends ConsumerState { ), // Legal links and logout (hidden in remote mode) - if (!GlobalConfig.remote.isActive && - loginType != LoginType.none) ...[ + if (!GlobalConfig.remote.isActive && isLoggedIn) ...[ AppGap.md(), const AppDivider(), AppGap.md(), diff --git a/lib/components/styled/general_settings_widget/language_tile.dart b/lib/components/styled/general_settings_widget/language_tile.dart index 044aadefd..16e16a7b2 100644 --- a/lib/components/styled/general_settings_widget/language_tile.dart +++ b/lib/components/styled/general_settings_widget/language_tile.dart @@ -31,6 +31,10 @@ class _LanguageTileState extends ConsumerState { Widget build(BuildContext context) { return InkWell( onTap: () { + // The picker lists every language's native name at once (简体中文, ไทย, + // العربية …). All subset fonts are eager-loaded (pubspec `fonts:`), and + // each row wraps its title in Localizations.override(locale) below so + // the correct per-language fallback family is applied. showSimpleAppDialog( context, content: _localeList(), @@ -84,9 +88,16 @@ class _LanguageTileState extends ConsumerState { return AppListTile( key: Key('locale_item_${locale.toLanguageTag()}'), selected: isSelected, - title: Semantics( - identifier: 'now-locale-item-${locale.toLanguageTag()}', - child: AppText.labelLarge(locale.displayText)), + // Override locale per item so AppText.resolve() picks THIS + // language's fallback family (e.g. the "ไทย" row resolves with + // Thai → NotoSansThai), not the app's current locale. + title: Localizations.override( + context: context, + locale: locale, + child: Semantics( + identifier: 'now-locale-item-${locale.toLanguageTag()}', + child: AppText.labelLarge(locale.displayText)), + ), trailing: isSelected ? Semantics( identifier: 'now-locale-item-checked', diff --git a/lib/components/styled/top_bar.dart b/lib/components/styled/top_bar.dart deleted file mode 100644 index 798bb9076..000000000 --- a/lib/components/styled/top_bar.dart +++ /dev/null @@ -1,74 +0,0 @@ -// ignore_for_file: public_member_api_docs, sort_constructors_first - -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:privacy_gui/di.dart'; -import 'package:privacy_gui/components/styled/menus/menu_consts.dart'; -import 'package:privacy_gui/components/styled/menus/widgets/menu_holder.dart'; -import 'package:ui_kit_library/ui_kit.dart'; - -import 'package:privacy_gui/localization/localization_hook.dart'; -import 'package:privacy_gui/components/styled/general_settings_widget/general_settings_widget.dart'; -import 'package:privacy_gui/util/debug_mixin.dart'; -import 'package:privacy_gui/util/app_utils.dart'; - -class TopBar extends ConsumerStatefulWidget { - final void Function(int)? onMenuClick; - const TopBar({ - super.key, - this.onMenuClick, - }); - - @override - ConsumerState createState() => _TopBarState(); -} - -class _TopBarState extends ConsumerState with DebugObserver { - @override - Widget build(BuildContext context) { - // Watch Theme.of(context) to trigger rebuild when global theme changes - Theme.of(context); - - // Use dark theme's color scheme for TopBar - final darkTheme = getIt.get(instanceName: 'darkThemeData'); - final colorScheme = darkTheme.colorScheme; - - return SafeArea( - bottom: false, - child: GestureDetector( - onTap: () { - if (increase()) { - Utils.exportLogFile(context); - } - }, - child: Theme( - data: darkTheme, - child: AppSurface( - height: 64, - padding: const EdgeInsets.only( - left: 24.0, - right: 24, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - AppText.titleLarge(loc(context).appTitle, - color: colorScheme.onSurface), - MenuHolder(type: MenuDisplay.top), - const Wrap( - children: [ - Padding( - padding: EdgeInsets.all(4.0), - child: GeneralSettingsWidget(), - ), - ], - ), - ], - ), - ), - ), - ), - ); - } -} diff --git a/lib/components/ui_kit_page_view.dart b/lib/components/ui_kit_page_view.dart index b44187638..b2d3ca883 100644 --- a/lib/components/ui_kit_page_view.dart +++ b/lib/components/ui_kit_page_view.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:privacy_gui/localization/localization_hook.dart'; -import 'package:privacy_gui/components/styled/top_bar.dart'; +import 'package:privacy_gui/page/shell/usp_top_bar.dart'; import 'package:privacy_gui/route/navigation_extensions.dart'; import 'package:ui_kit_library/ui_kit.dart'; @@ -383,7 +383,7 @@ class _UiKitPageViewState extends ConsumerState { Widget topBarContent = topBarWidget; // If it's a PreferredSize wrapper around TopBar, extract the TopBar - if (topBarContent is PreferredSize && topBarContent.child is TopBar) { + if (topBarContent is PreferredSize && topBarContent.child is UspTopBar) { topBarContent = topBarContent.child; } @@ -481,9 +481,9 @@ class _UiKitPageViewState extends ConsumerState { } // Create default TopBar for PrivacyGUI integration - return const PreferredSize( + return PreferredSize( preferredSize: Size.fromHeight(kDefaultToolbarHeight), - child: TopBar(), + child: UspTopBar(), ); } diff --git a/lib/components/views/service_error_view.dart b/lib/components/views/service_error_view.dart index 053bdba4b..65f2f165e 100644 --- a/lib/components/views/service_error_view.dart +++ b/lib/components/views/service_error_view.dart @@ -16,16 +16,34 @@ class ServiceErrorView extends StatelessWidget { /// Called when the user taps retry (e.g. re-fetch with forceRemote). final VoidCallback onRetry; + /// Page-appropriate title. Defaults to a neutral "something went wrong" + /// message; callers SHOULD pass a context-specific title (e.g. + /// `loc(context).failedToLoadSettings` on settings pages, + /// `loc(context).unableToLoadTopology` on the topology page). + final String? title; + + /// Optional secondary action shown as a text button below retry + /// (e.g. "Log out" as an escape hatch when a page cannot load). + /// Both [secondaryLabel] and [onSecondary] must be provided (or neither). + final String? secondaryLabel; + final VoidCallback? onSecondary; + const ServiceErrorView({ super.key, required this.error, required this.onRetry, - }); + this.title, + this.secondaryLabel, + this.onSecondary, + }) : assert((secondaryLabel == null) == (onSecondary == null), + 'secondaryLabel and onSecondary must be provided together'); @override Widget build(BuildContext context) { final message = error != null ? localizeServiceError(context, error!) : null; + final secondaryLabel = this.secondaryLabel; + final onSecondary = this.onSecondary; return Center( child: Column( mainAxisSize: MainAxisSize.min, @@ -33,7 +51,7 @@ class ServiceErrorView extends StatelessWidget { AppIcon.font(Icons.error_outline, size: 48, color: Theme.of(context).colorScheme.error), AppGap.xl(), - AppText.titleMedium(loc(context).failedToLoadSettings), + AppText.titleMedium(title ?? loc(context).errorUnexpected), if (message != null) ...[ AppGap.sm(), AppText.bodyMedium(message), @@ -43,6 +61,13 @@ class ServiceErrorView extends StatelessWidget { label: loc(context).retry, onTap: onRetry, ), + if (secondaryLabel != null && onSecondary != null) ...[ + AppGap.md(), + AppButton.text( + label: secondaryLabel, + onTap: onSecondary, + ), + ], ], ), ); diff --git a/lib/constants/pref_key.dart b/lib/constants/pref_key.dart index 8d4d0eb52..fc72711c5 100644 --- a/lib/constants/pref_key.dart +++ b/lib/constants/pref_key.dart @@ -34,7 +34,6 @@ const pSessionToken = 'SessionToken'; const pSessionTokenTs = 'SessionTokenTimeStamp'; const pUserPassword = 'UserPassword'; const pUsername = 'Username'; -const pLocalPassword = 'LocalPassword'; const pBiometrics = 'Biometrics'; const pLinksysToken = 'LinksysToken'; const pLinksysTokenTs = 'LinksysTokenTs'; diff --git a/lib/core/cloud/providers/remote_assistance/device_credentials_provider.dart b/lib/core/cloud/providers/remote_assistance/device_credentials_provider.dart index 1e7d430fa..1fc466811 100644 --- a/lib/core/cloud/providers/remote_assistance/device_credentials_provider.dart +++ b/lib/core/cloud/providers/remote_assistance/device_credentials_provider.dart @@ -1,6 +1,5 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:privacy_gui/core/session/providers/session_provider.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; import 'package:privacy_gui/core/cloud/providers/remote_assistance/remote_client_provider.dart'; @@ -17,18 +16,17 @@ final deviceCredentialsProvider = Provider((ref) { final deviceInfo = session.deviceInfo; if (deviceInfo == null) return null; - // Get master node for MAC address (from nodeModels) - final masterNode = - devicesData.nodeModels.where((n) => n.isMaster).firstOrNull; - if (masterNode == null) return null; - - // Get master device for hostsDeviceId (UUID) (from deviceModels) - final masterDevice = devicesData.deviceModels.masterNode; - if (masterDevice?.hostsDeviceId == null) return null; + // Get master node for MAC address and hostsDeviceId (UUID) + final master = devicesData.master; + // Master's hostsDeviceId comes from the Hosts table during MeshNetwork build. + // Guardian Remote Assistance requires the Hosts DeviceID/UUID, NOT the MAC — + // without it, session lookup / PIN creation would receive the wrong value. + final hostsDeviceId = master.hostsDeviceId; + if (hostsDeviceId == null || hostsDeviceId.isEmpty) return null; return DeviceCredentials( serialNumber: deviceInfo.serialNumber, - macAddress: masterNode.deviceId, - deviceUUID: masterDevice!.hostsDeviceId!, + macAddress: master.deviceId, + deviceUUID: hostsDeviceId, ); }); diff --git a/lib/core/connection/providers/app_connection_state_provider.dart b/lib/core/connection/providers/app_connection_state_provider.dart index 5df20194b..4630ebb82 100644 --- a/lib/core/connection/providers/app_connection_state_provider.dart +++ b/lib/core/connection/providers/app_connection_state_provider.dart @@ -63,8 +63,7 @@ class AppConnectionStateNotifier extends Notifier { ref.listen(authProvider, (_, next) { if (next.isLoading) return; - final loginType = next.value?.loginType; - if (loginType == null || loginType == LoginType.none) { + if (!(next.value?.isLoggedIn ?? false)) { _probeTimer?.cancel(); _probeTimer = null; _cooldownTimer?.cancel(); diff --git a/lib/core/connection/services/recovery_probe_service.dart b/lib/core/connection/services/recovery_probe_service.dart index 3d7019ee9..b1aaf86af 100644 --- a/lib/core/connection/services/recovery_probe_service.dart +++ b/lib/core/connection/services/recovery_probe_service.dart @@ -43,7 +43,7 @@ class RecoveryProbeService { } try { - await authCoordinator.restoreSession(); + await authCoordinator.restoreSession(isRecovering: true); logger.d('[Recovery] Session restored'); } catch (e) { logger.d('[Recovery] Session restore failed: $e'); diff --git a/lib/core/usp/providers/sse_providers.dart b/lib/core/usp/providers/sse_providers.dart index 3a52f7408..ec8ed50c1 100644 --- a/lib/core/usp/providers/sse_providers.dart +++ b/lib/core/usp/providers/sse_providers.dart @@ -174,10 +174,9 @@ final sseBootstrapProvider = FutureProvider((ref) async { if (!GlobalConfig.remote.isActive) { try { await bridge.health().timeout(const Duration(seconds: 5)); - logger.d('[USP][SSE][Bootstrap]: Bridge health check passed'); + logger.d('[SSE]: Bridge health check passed'); } catch (e) { - logger.w( - '[USP][SSE][Bootstrap]: Bridge health check failed: $e — continuing'); + logger.w('[SSE]: Bridge health check failed: $e — continuing'); } } @@ -187,6 +186,6 @@ final sseBootstrapProvider = FutureProvider((ref) async { // which causes 503 errors due to the single-threaded OBUSPA backend. await manager.connect(); - logger.d('[USP][SSE][Bootstrap]: Complete — SSE connected, ' + logger.d('[SSE]: Complete — SSE connected, ' 'core subscriptions deferred to orchestrator after domain ready'); }); diff --git a/lib/core/usp/providers/usp_auth_coordinator.dart b/lib/core/usp/providers/usp_auth_coordinator.dart index 02249c7ee..0af6aa086 100644 --- a/lib/core/usp/providers/usp_auth_coordinator.dart +++ b/lib/core/usp/providers/usp_auth_coordinator.dart @@ -2,14 +2,14 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:privacy_gui/constants/error_code.dart'; -import 'package:privacy_gui/constants/pref_key.dart'; import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/core/utils/logger.dart'; import 'package:privacy_gui/core/usp/providers/usp_client_provider.dart'; import 'package:privacy_gui/core/usp/services/usp_client.dart'; +import 'usp_token_storage.dart'; + /// Coordinates USP authentication alongside JNAP authentication. /// /// USP auth is an **additional local auth channel** — it never replaces JNAP auth. @@ -19,14 +19,17 @@ import 'package:privacy_gui/core/usp/services/usp_client.dart'; /// Key behaviors: /// - [syncAfterLocalLogin]: Auto-login USP after successful JNAP local login /// - [syncAfterLogout]: Logout USP when JNAP logs out -/// - [restoreSession]: Re-authenticate USP using stored password on page reload +/// - [restoreSession]: Re-authenticate USP using stored token on page reload /// - [ensureAuth]: Proactive token refresh triggered by SSE heartbeat /// +/// Token persistence uses sessionStorage (cleared on browser close) for security. +/// Password is never stored — only used for initial login. +/// /// USP login failure never blocks JNAP operations — [ProtocolResolver] falls /// back to JNAP when `isAuthenticated` is false. class UspAuthCoordinator { final UspClient? _usp; - final FlutterSecureStorage _storage; + final UspTokenStorage _tokenStorage; DateTime? _lastTokenRefresh; Completer? _refreshInProgress; @@ -35,7 +38,8 @@ class UspAuthCoordinator { bool? _lastRestoreResult; /// Cooldown period after a failed restore attempt to prevent rapid retries. - static const Duration _restoreCooldown = Duration(seconds: 5); + /// Short (1s) since refreshToken doesn't risk account lockout like login does. + static const Duration _restoreCooldown = Duration(seconds: 1); /// Called when proactive refresh gets 401 — session externally terminated. VoidCallback? onForceLogout; @@ -69,19 +73,30 @@ class UspAuthCoordinator { return _accountLockedPattern.hasMatch(error.toString()); } - UspAuthCoordinator(this._usp, this._storage) { - _usp?.onReauthRequired = restoreSession; + UspAuthCoordinator(this._usp, this._tokenStorage) { + // 401 retry is not recovery — force logout on failure + _usp?.onReauthRequired = () => restoreSession(isRecovering: false); _usp?.onRefreshTokenSuccess = () { _lastTokenRefresh = DateTime.now(); + _persistToken(); }; } + /// Persists the current session token to storage for page reload recovery. + void _persistToken() { + final token = _usp?.sessionToken; + if (token != null && token.isNotEmpty) { + _tokenStorage.save(token); + } + } + /// Called after JNAP localLogin succeeds — auto-sync USP authentication. Future syncAfterLocalLogin(String password) async { if (_usp == null) return; try { await _usp.login(password); _lastTokenRefresh = DateTime.now(); + _persistToken(); logger.d('[USP][Auth]: USP login synced successfully'); } catch (e) { // USP login failure does not affect JNAP — ProtocolResolver @@ -93,6 +108,7 @@ class UspAuthCoordinator { /// Called during JNAP logout — sync logout USP. Future syncAfterLogout() async { _lastTokenRefresh = null; + _tokenStorage.clear(); if (_usp == null || !_usp.isAuthenticated) return; try { await _usp.logout(); @@ -102,23 +118,24 @@ class UspAuthCoordinator { } } - /// Re-authenticates USP using token refresh or stored password. + /// Re-authenticates USP using stored token from sessionStorage. + /// + /// Strategy (token-only): + /// 1. If WASM client has a token in memory → try `refreshToken()` first + /// 2. If no in-memory token → try restoring from sessionStorage via + /// `refreshToken(storedToken)` which validates and restores the session + /// 3. If no stored token or refresh failed → trigger force logout /// - /// Strategy (token-first): - /// 1. If `isAuthenticated` is true → try `refreshToken()` first - /// - Success → done, no login needed - /// - 401 → fall through to password login - /// 2. If no token or refresh failed → login with stored password + /// Password is never stored — this is intentional for security. + /// If the token expires, the user must enter their password again. /// - /// This avoids unnecessary login calls when the session is still valid, - /// reducing account-lock risk from repeated password attempts. + /// Set [isRecovering] to true when the app is waiting for the router to + /// recover (e.g., after reboot). In this case, failure is expected and + /// force logout is suppressed — the recovery probe will retry later. /// /// Multiple concurrent calls are coalesced — only the first triggers an /// actual restore; subsequent callers await the same result. - /// - /// Failed login attempts have a cooldown period to prevent rapid retries - /// that could lock the account. - Future restoreSession() async { + Future restoreSession({bool isRecovering = false}) async { if (_usp == null) { logger.w('[USP][Auth]: restoreSession skipped: UspClient is null'); return; @@ -133,7 +150,7 @@ class UspAuthCoordinator { _restoreInProgress = Completer(); try { - final result = await _restoreSessionImpl(); + final result = await _restoreSessionImpl(isRecovering: isRecovering); _restoreInProgress!.complete(result); } catch (e) { _restoreInProgress!.completeError(e); @@ -142,63 +159,126 @@ class UspAuthCoordinator { } } - /// Implementation of session restore with token-first strategy. - Future _restoreSessionImpl() async { - // Step 1: If we have a token, try refreshing it first + /// Implementation of session restore with token-only strategy. + Future _restoreSessionImpl({required bool isRecovering}) async { + // Step 1: If WASM client already has a token, try refreshing it if (_usp!.isAuthenticated) { try { await _usp.refreshToken(); _lastTokenRefresh = DateTime.now(); - logger.d('[USP][Auth]: restoreSession via refreshToken succeeded'); + _persistToken(); + logger.d('[USP][Auth]: restoreSession via in-memory token succeeded'); return true; } catch (e) { if (_isAuthError(e)) { - logger.d('[USP][Auth]: refreshToken got 401, falling back to login'); - // Fall through to password login + logger.d('[USP][Auth]: in-memory token refresh got 401, ' + 'trying stored token'); } else { - logger.w('[USP][Auth]: refreshToken failed (non-auth): $e'); - // Non-auth error (network, etc.) — still try password login as fallback + logger.w('[USP][Auth]: in-memory token refresh failed: $e'); } + // Fall through to try stored token } } - // Step 2: Fall back to password login - // Check cooldown to prevent account lock from rapid retries + // Step 2: Try restoring from sessionStorage + final storedToken = _tokenStorage.load(); + if (storedToken == null || storedToken.isEmpty) { + logger.d('[USP][Auth]: restoreSession skipped: no stored token'); + _triggerForceLogoutIfNotRecovering(isRecovering); + return false; + } + + // Check cooldown to prevent rapid retries final lastAttempt = _lastRestoreAttempt; if (lastAttempt != null && _lastRestoreResult == false && DateTime.now().difference(lastAttempt) < _restoreCooldown) { - logger.d( - '[USP][Auth]: restoreSession skipped: cooldown after failed login'); + logger.d('[USP][Auth]: restoreSession skipped: cooldown after failure'); return false; } - final result = await _loginWithStoredPassword(); _lastRestoreAttempt = DateTime.now(); - _lastRestoreResult = result; - return result; - } - /// Shared login logic — reads stored password and calls [UspClient.login]. - /// Returns true if login succeeded, false otherwise. Never throws. - Future _loginWithStoredPassword() async { - final password = await _storage.read(key: pLocalPassword); - if (password == null || password.isEmpty) { - logger.w('[USP][Auth]: restoreSession skipped: no stored password'); - return false; - } try { - await _usp!.login(password); + // refreshToken(token) validates the external token with the server + // and restores the session if valid + await _usp.refreshToken(token: storedToken); _lastTokenRefresh = DateTime.now(); - logger.d( - '[USP][Auth]: restoreSession login done, isAuthenticated=${_usp.isAuthenticated}'); + _persistToken(); + _lastRestoreResult = true; + logger.d('[USP][Auth]: restoreSession via stored token succeeded'); return true; } catch (e) { - logger.w('[USP][Auth]: restoreSession login failed: $e'); + _lastRestoreResult = false; + if (_isAuthError(e)) { + logger.d('[USP][Auth]: stored token expired/invalid, clearing'); + _tokenStorage.clear(); + _triggerForceLogoutIfNotRecovering(isRecovering); + } else { + logger.w('[USP][Auth]: stored token refresh failed: $e'); + // Network error — don't force logout, might recover + } return false; } } + /// Triggers force logout unless in recovery mode. + void _triggerForceLogoutIfNotRecovering(bool isRecovering) { + if (isRecovering) { + logger.d('[USP][Auth]: Suppressing force logout — in recovery mode'); + return; + } + logger.w('[USP][Auth]: Triggering force logout'); + onForceLogout?.call(); + } + + /// Re-authenticates with a new password after password change. + /// + /// Call this after successfully changing the admin password to: + /// 1. Logout the old session (old token becomes invalid) + /// 2. Login with the new password + /// 3. Persist the new token + /// + /// Throws [ServiceError] on failure — caller should handle gracefully + /// (e.g., redirect to login page). + Future reloginWithNewPassword(String newPassword) async { + if (_usp == null) { + logger + .w('[USP][Auth]: reloginWithNewPassword skipped: UspClient is null'); + throw const ServiceNotInitializedError( + detail: 'USP client not available'); + } + + logger.d('[USP][Auth]: Re-authenticating with new password'); + + // Clear old token first + _tokenStorage.clear(); + _lastTokenRefresh = null; + + try { + // Logout old session (best-effort, ignore errors) + try { + await _usp.logout(); + } catch (e) { + logger.d('[USP][Auth]: Old session logout failed (expected): $e'); + } + + // Login with new password + await _usp.login(newPassword); + if (!_usp.isAuthenticated) { + throw const InvalidCredentialsError(); + } + + _lastTokenRefresh = DateTime.now(); + _persistToken(); + logger.d('[USP][Auth]: Re-authentication with new password succeeded'); + } catch (e) { + logger.w('[USP][Auth]: Re-authentication failed: $e'); + if (e is ServiceError) rethrow; + throw UnexpectedError(originalError: e, detail: e.toString()); + } + } + /// Attempts USP login independently (not as sync after JNAP). /// /// Used as fallback when JNAP is unavailable (e.g., firmware disabled JNAP). @@ -215,6 +295,7 @@ class UspAuthCoordinator { throw const InvalidCredentialsError(); } _lastTokenRefresh = DateTime.now(); + _persistToken(); logger.d('[USP][Auth]: USP standalone login succeeded'); } catch (e) { logger.w('[USP][Auth]: USP standalone login failed: $e'); @@ -278,10 +359,12 @@ class UspAuthCoordinator { try { await _usp.refreshToken(); _lastTokenRefresh = DateTime.now(); + _persistToken(); logger.d('[USP][Auth]: Proactive token refresh succeeded'); } catch (e) { if (_isAuthError(e)) { - _lastTokenRefresh = null; // Allow immediate retry if logout is delayed + _lastTokenRefresh = null; + _tokenStorage.clear(); logger.w('[USP][Auth]: Proactive refresh got 401 — forcing logout: $e'); onForceLogout?.call(); } else { @@ -297,6 +380,6 @@ class UspAuthCoordinator { final uspAuthCoordinatorProvider = Provider((ref) { return UspAuthCoordinator( ref.watch(uspClientProvider), - const FlutterSecureStorage(), + UspTokenStorage(), ); }); diff --git a/lib/core/usp/providers/usp_token_storage.dart b/lib/core/usp/providers/usp_token_storage.dart new file mode 100644 index 000000000..13451ba6a --- /dev/null +++ b/lib/core/usp/providers/usp_token_storage.dart @@ -0,0 +1,44 @@ +import 'package:flutter/foundation.dart'; + +import 'usp_token_storage_stub.dart' + if (dart.library.js_interop) 'usp_token_storage_web.dart'; + +/// Storage for USP session tokens. +/// +/// On Web: uses sessionStorage (cleared when browser tab/window closes) +/// On other platforms: no-op (USP is only available on Web) +/// +/// sessionStorage is preferred over localStorage because: +/// - Cleared when browser tab/window closes (security) +/// - Not shared across tabs (prevents session conflicts) +/// - Sufficient for page refresh recovery (main use case) +abstract class UspTokenStorage { + factory UspTokenStorage() { + if (kIsWeb) { + return createUspTokenStorage(); + } + return _StubTokenStorage(); + } + + /// Saves the token to storage. + void save(String token); + + /// Loads the token from storage. + /// Returns null if not found. + String? load(); + + /// Clears the token from storage. + void clear(); +} + +/// No-op implementation for non-Web platforms. +class _StubTokenStorage implements UspTokenStorage { + @override + void save(String token) {} + + @override + String? load() => null; + + @override + void clear() {} +} diff --git a/lib/core/usp/providers/usp_token_storage_stub.dart b/lib/core/usp/providers/usp_token_storage_stub.dart new file mode 100644 index 000000000..d0c3076b6 --- /dev/null +++ b/lib/core/usp/providers/usp_token_storage_stub.dart @@ -0,0 +1,15 @@ +import 'usp_token_storage.dart'; + +/// Creates a stub token storage for non-Web platforms (VM/tests). +UspTokenStorage createUspTokenStorage() => _StubTokenStorageImpl(); + +class _StubTokenStorageImpl implements UspTokenStorage { + @override + void save(String token) {} + + @override + String? load() => null; + + @override + void clear() {} +} diff --git a/lib/core/usp/providers/usp_token_storage_web.dart b/lib/core/usp/providers/usp_token_storage_web.dart new file mode 100644 index 000000000..138339402 --- /dev/null +++ b/lib/core/usp/providers/usp_token_storage_web.dart @@ -0,0 +1,76 @@ +// ignore: avoid_web_libraries_in_flutter +import 'dart:js_interop'; +import 'dart:js_interop_unsafe'; + +import 'package:privacy_gui/core/utils/logger.dart'; + +import 'usp_token_storage.dart'; + +/// Creates a Web-based token storage using sessionStorage. +UspTokenStorage createUspTokenStorage() => _WebTokenStorage(); + +/// Web implementation using browser sessionStorage. +class _WebTokenStorage implements UspTokenStorage { + static const _key = 'usp_session_token'; + + @override + void save(String token) { + try { + _sessionStorage?.setItem(_key, token); + } catch (e) { + // sessionStorage may be unavailable (private browsing, quota exceeded, etc.) + logger.w('[UspTokenStorage]: save failed: $e'); + } + } + + @override + String? load() { + try { + return _sessionStorage?.getItem(_key); + } catch (e) { + logger.w('[UspTokenStorage]: load failed: $e'); + return null; + } + } + + @override + void clear() { + try { + _sessionStorage?.removeItem(_key); + } catch (e) { + logger.w('[UspTokenStorage]: clear failed: $e'); + } + } + + /// Accessor for browser's sessionStorage object. + _SessionStorage? get _sessionStorage { + try { + final storage = globalContext['sessionStorage']; + if (storage == null || storage.isUndefinedOrNull) return null; + return _SessionStorage(storage as JSObject); + } catch (e) { + return null; + } + } +} + +/// Minimal wrapper around browser sessionStorage API. +class _SessionStorage { + final JSObject _storage; + + _SessionStorage(this._storage); + + void setItem(String key, String value) { + _storage.callMethod('setItem'.toJS, key.toJS, value.toJS); + } + + String? getItem(String key) { + final result = _storage.callMethod('getItem'.toJS, key.toJS); + if (result == null || result.isUndefinedOrNull) return null; + return (result as JSString).toDart; + } + + void removeItem(String key) { + _storage.callMethod('removeItem'.toJS, key.toJS); + } +} diff --git a/lib/core/usp/services/bridge_request_throttler.dart b/lib/core/usp/services/bridge_request_throttler.dart index fba6c6204..c330fd7d7 100644 --- a/lib/core/usp/services/bridge_request_throttler.dart +++ b/lib/core/usp/services/bridge_request_throttler.dart @@ -148,7 +148,7 @@ class BridgeRequestThrottler { void _dispatch(_PendingRequest pending) { _active++; _inFlight[pending.cacheKey] = pending; - logger.d('[Throttler]: Dispatch (active=$_active, queue=${_queue.length}): ' + logger.t('[Throttler]: Dispatch (active=$_active, queue=${_queue.length}): ' '${pending.cacheKey}'); Future(() async { diff --git a/lib/core/usp/services/sse_connection_manager.dart b/lib/core/usp/services/sse_connection_manager.dart index 8acc95c88..12a08a828 100644 --- a/lib/core/usp/services/sse_connection_manager.dart +++ b/lib/core/usp/services/sse_connection_manager.dart @@ -112,7 +112,7 @@ class SseConnectionManager { // Lock: if connect is already in progress, await it and return. if (_connectInProgress != null) { - logger.d('[USP][SSE]: connect() already in progress — awaiting'); + logger.d('[SSE]: connect() already in progress — awaiting'); await _connectInProgress!.future; return; } @@ -126,7 +126,7 @@ class SseConnectionManager { _streamEndHandled = false; connectionState.value = SseConnectionState.connecting; - logger.d('[USP][SSE]: Connecting...'); + logger.d('[SSE]: Connecting...'); final stream = _bridge.notifications(); _sseSubscription = stream.listen( @@ -136,7 +136,7 @@ class SseConnectionManager { ); _connectInProgress!.complete(); } catch (e) { - logger.w('[USP][SSE]: Failed to open stream: $e'); + logger.w('[SSE]: Failed to open stream: $e'); if (!_connectInProgress!.isCompleted) { _connectInProgress!.completeError(e); } @@ -160,7 +160,7 @@ class SseConnectionManager { onDisconnected?.call(); } connectionState.value = SseConnectionState.disconnected; - logger.d('[USP][SSE]: Disconnected (intentional)'); + logger.d('[SSE]: Disconnected (intentional)'); } /// Attempts to reconnect from [SseConnectionState.suspended] or @@ -175,7 +175,7 @@ class SseConnectionManager { state == SseConnectionState.reconnecting) { return false; } - logger.d('[USP][SSE]: Manual reconnect requested (was: ${state.name})'); + logger.d('[SSE]: Manual reconnect requested (was: ${state.name})'); _reconnectAttempt = 0; await connect(); return true; @@ -202,10 +202,8 @@ class SseConnectionManager { // Skip debug events from UspBridgeClient internal diagnostics FIRST — // these are synthetic events emitted before the Fetch returns and must // NOT trigger a connected transition or subscription re-registration. - if (event.event == '_debug') { - logger.d('[USP][SSE]: debug: ${event.data}'); - return; - } + // Skip synthetic _debug events — they are for development diagnostics only + if (event.event == '_debug') return; // Reset heartbeat watchdog on real events only _resetHeartbeatWatchdog(); @@ -214,7 +212,7 @@ class SseConnectionManager { if (connectionState.value != SseConnectionState.connected) { connectionState.value = SseConnectionState.connected; _reconnectAttempt = 0; - logger.d('[USP][SSE]: Connected (event: ${event.event})'); + logger.d('[SSE]: Connected (event: ${event.event})'); onConnected?.call(); } @@ -223,12 +221,12 @@ class SseConnectionManager { } void _onError(Object error) { - logger.w('[USP][SSE]: Stream error: $error'); + logger.w('[SSE]: Stream error: $error'); _handleStreamEnd(); } void _onDone() { - logger.d('[USP][SSE]: Stream done (server closed connection)'); + logger.d('[SSE]: Stream done (server closed connection)'); _handleStreamEnd(); } @@ -265,7 +263,7 @@ class SseConnectionManager { final timeout = _heartbeatConfig.timeout; _heartbeatWatchdog = Timer(timeout, () { - logger.w('[USP][SSE]: Heartbeat timeout (${timeout.inSeconds}s) ' + logger.w('[SSE]: Heartbeat timeout (${timeout.inSeconds}s) ' '— connection may be stale'); _sseSubscription?.cancel(); _handleStreamEnd(); @@ -297,7 +295,7 @@ class SseConnectionManager { if (_reconnectAttempt > _maxRetries) { connectionState.value = SseConnectionState.suspended; - logger.w('[USP][SSE]: Max retries ($_maxRetries) reached — suspended. ' + logger.w('[SSE]: Max retries ($_maxRetries) reached — suspended. ' 'Call tryReconnect() or wait for lifecycle resume.'); return; } @@ -305,7 +303,7 @@ class SseConnectionManager { connectionState.value = SseConnectionState.reconnecting; final delay = _nextBackoff; - logger.d('[USP][SSE]: Reconnecting in ${delay.inSeconds}s ' + logger.d('[SSE]: Reconnecting in ${delay.inSeconds}s ' '(attempt #$_reconnectAttempt/$_maxRetries)'); _reconnectTimer = Timer(delay, () { diff --git a/lib/core/usp/services/sse_event_router.dart b/lib/core/usp/services/sse_event_router.dart index ec9df53ee..0e0f62ec6 100644 --- a/lib/core/usp/services/sse_event_router.dart +++ b/lib/core/usp/services/sse_event_router.dart @@ -67,10 +67,10 @@ class SseEventRouter { break; case 'turbo_channel': // Future: route to turbo channel coordinator - logger.d('[USP][SSE][Router]: turbo_channel event: ${event.data}'); + logger.d('[SSE]: turbo_channel event: ${event.data}'); break; default: - logger.d('[USP][SSE][Router]: Unknown event type: ${event.event}'); + logger.d('[SSE]: Unknown event type: ${event.event}'); break; } } @@ -80,7 +80,7 @@ class SseEventRouter { try { json = jsonDecode(event.data) as Map; } catch (e) { - logger.w('[USP][SSE][Router]: Failed to parse notification JSON: $e'); + logger.w('[SSE]: Failed to parse notification JSON: $e'); return; } @@ -88,8 +88,7 @@ class SseEventRouter { final type = json['type'] as String?; if (subscriptionId == null || type == null) { - logger.w( - '[USP][SSE][Router]: Notification missing subscription_id or type: ' + logger.w('[SSE]: Notification missing subscription_id or type: ' '${event.data}'); return; } @@ -100,7 +99,8 @@ class SseEventRouter { payload: json, ); - logger.d('[USP][SSE][Router]: Routing: $notification'); + logger.t('[SSE]: $subscriptionId ($type)\n' + ' ${const JsonEncoder.withIndent(' ').convert(json).replaceAll('\n', '\n ')}'); // Route to subscription-specific handlers final handlers = _handlers[subscriptionId]; @@ -109,7 +109,7 @@ class SseEventRouter { try { handler(notification); } catch (e) { - logger.w('[USP][SSE][Router]: Handler error for $subscriptionId: $e'); + logger.w('[SSE]: Handler error for $subscriptionId: $e'); } } } @@ -119,7 +119,7 @@ class SseEventRouter { try { handler(notification); } catch (e) { - logger.w('[USP][SSE][Router]: Wildcard handler error: $e'); + logger.w('[SSE]: Wildcard handler error: $e'); } } } diff --git a/lib/core/usp/services/sse_local_strategy.dart b/lib/core/usp/services/sse_local_strategy.dart index 416734cf1..2dd85e4df 100644 --- a/lib/core/usp/services/sse_local_strategy.dart +++ b/lib/core/usp/services/sse_local_strategy.dart @@ -31,7 +31,7 @@ class LocalSseStrategy implements SseOperationStrategy { for (final sub in subscriptions) { try { - logger.d('[SSE][Local]: Registering ${sub.subscriptionId}'); + logger.d('[SSE]: Registering ${sub.subscriptionId}'); await _bridge.subscribe( subscriptionId: sub.subscriptionId, @@ -49,12 +49,12 @@ class LocalSseStrategy implements SseOperationStrategy { // Small breathing room for embedded router between requests await Future.delayed(const Duration(milliseconds: 50)); } catch (e) { - logger.w('[SSE][Local]: Failed to register ${sub.subscriptionId}: $e'); + logger.w('[SSE]: Failed to register ${sub.subscriptionId}: $e'); } } logger.d( - '[SSE][Local]: Registered ${records.length}/${subscriptions.length} subscriptions'); + '[SSE]: Registered ${records.length}/${subscriptions.length} subscriptions'); return records; } @@ -63,9 +63,9 @@ class LocalSseStrategy implements SseOperationStrategy { for (final id in subscriptionIds) { try { await _bridge.unsubscribe(subscriptionId: id); - logger.d('[SSE][Local]: Unregistered $id'); + logger.d('[SSE]: Unregistered $id'); } catch (e) { - logger.w('[SSE][Local]: Failed to unregister $id: $e'); + logger.w('[SSE]: Failed to unregister $id: $e'); } } } @@ -74,13 +74,12 @@ class LocalSseStrategy implements SseOperationStrategy { Future onSseConnected( List existingRecords) async { if (existingRecords.isEmpty) { - logger.d( - '[SSE][Local]: onConnected — no existing subscriptions to resubscribe'); + logger.d('[SSE]: onConnected — no existing subscriptions to resubscribe'); return; } logger.d( - '[SSE][Local]: onConnected — resubscribing ${existingRecords.length} subscriptions'); + '[SSE]: onConnected — resubscribing ${existingRecords.length} subscriptions'); // Bridge is idempotent, safe to re-register directly for (final record in existingRecords) { @@ -91,8 +90,7 @@ class LocalSseStrategy implements SseOperationStrategy { notifType: _notifTypeToInt(record.notifType), ); } catch (e) { - logger.w( - '[SSE][Local]: Failed to resubscribe ${record.subscriptionId}: $e'); + logger.w('[SSE]: Failed to resubscribe ${record.subscriptionId}: $e'); } } } @@ -100,7 +98,7 @@ class LocalSseStrategy implements SseOperationStrategy { @override Future onSseDisconnected({required bool intentional}) async { // Local: Bridge handles cleanup, no action needed - logger.d('[SSE][Local]: onDisconnected (intentional=$intentional)'); + logger.d('[SSE]: onDisconnected (intentional=$intentional)'); } @override diff --git a/lib/core/usp/services/sse_remote_strategy.dart b/lib/core/usp/services/sse_remote_strategy.dart index 498e754d4..15809c999 100644 --- a/lib/core/usp/services/sse_remote_strategy.dart +++ b/lib/core/usp/services/sse_remote_strategy.dart @@ -42,8 +42,7 @@ class RemoteSseStrategy implements SseOperationStrategy { for (final sub in subscriptions) { final remoteId = _toRemoteId(sub.subscriptionId); try { - logger - .d('[SSE][Remote]: Registering ${sub.subscriptionId} as $remoteId'); + logger.d('[SSE]: Registering ${sub.subscriptionId} as $remoteId'); // Unregister first to avoid ID conflict try { @@ -70,12 +69,12 @@ class RemoteSseStrategy implements SseOperationStrategy { // Breathing room for Guardian between requests await Future.delayed(const Duration(milliseconds: 50)); } catch (e) { - logger.w('[SSE][Remote]: Failed to register ${sub.subscriptionId}: $e'); + logger.w('[SSE]: Failed to register ${sub.subscriptionId}: $e'); } } logger.d( - '[SSE][Remote]: Registered ${records.length}/${subscriptions.length} subscriptions'); + '[SSE]: Registered ${records.length}/${subscriptions.length} subscriptions'); return records; } @@ -85,9 +84,9 @@ class RemoteSseStrategy implements SseOperationStrategy { final remoteId = _toRemoteId(id); try { await _bridge.unsubscribe(subscriptionId: remoteId); - logger.d('[SSE][Remote]: Unregistered $id (as $remoteId)'); + logger.d('[SSE]: Unregistered $id (as $remoteId)'); } catch (e) { - logger.w('[SSE][Remote]: Failed to unregister $id: $e'); + logger.w('[SSE]: Failed to unregister $id: $e'); } } } @@ -97,7 +96,7 @@ class RemoteSseStrategy implements SseOperationStrategy { List existingRecords) async { // Remote: Do NOT auto resubscribe — orchestrator controls registration // This avoids duplicate registration when orchestrator has already registered - logger.d('[SSE][Remote]: onConnected — skipping auto resubscribe ' + logger.d('[SSE]: onConnected — skipping auto resubscribe ' '(orchestrator controls registration)'); } @@ -106,11 +105,11 @@ class RemoteSseStrategy implements SseOperationStrategy { if (intentional) { // Fire-and-forget cleanup: unregister all subscriptions on Guardian // Don't await — let it complete in background - logger.d('[SSE][Remote]: onDisconnected (intentional) — ' + logger.d('[SSE]: onDisconnected (intentional) — ' 'fire-and-forget cleanup'); _fireAndForgetCleanup(); } else { - logger.d('[SSE][Remote]: onDisconnected (unintentional) — ' + logger.d('[SSE]: onDisconnected (unintentional) — ' 'will resubscribe on reconnect via orchestrator'); } } diff --git a/lib/core/usp/services/sse_subscription_registry.dart b/lib/core/usp/services/sse_subscription_registry.dart index 288305ed5..31ebd06b1 100644 --- a/lib/core/usp/services/sse_subscription_registry.dart +++ b/lib/core/usp/services/sse_subscription_registry.dart @@ -36,14 +36,13 @@ class SseSubscriptionRegistry { .toList(); if (toRegister.isEmpty) { - logger.d('[SSE][Registry]: All ${subscriptions.length} subscriptions ' + logger.d('[SSE]: All ${subscriptions.length} subscriptions ' 'already registered, skipping'); return; } if (toRegister.length < subscriptions.length) { - logger.d( - '[SSE][Registry]: Skipping ${subscriptions.length - toRegister.length} ' + logger.d('[SSE]: Skipping ${subscriptions.length - toRegister.length} ' 'already registered subscriptions'); } @@ -53,7 +52,7 @@ class SseSubscriptionRegistry { _subscriptions[record.subscriptionId] = record; } - logger.d('[SSE][Registry]: Registered ${records.length} subscriptions, ' + logger.d('[SSE]: Registered ${records.length} subscriptions, ' 'total active: ${_subscriptions.length}'); } @@ -61,8 +60,7 @@ class SseSubscriptionRegistry { Future unregister(String subscriptionId) async { final record = _subscriptions.remove(subscriptionId); if (record == null) { - logger.d( - '[SSE][Registry]: Unregister $subscriptionId: not found, skipping'); + logger.d('[SSE]: Unregister $subscriptionId: not found, skipping'); return; } @@ -77,7 +75,7 @@ class SseSubscriptionRegistry { _subscriptions.clear(); await _strategy.unregisterSubscriptions(ids); - logger.d('[SSE][Registry]: Unregistered all ${ids.length} subscriptions'); + logger.d('[SSE]: Unregistered all ${ids.length} subscriptions'); } /// Called when SSE connects. Delegates to strategy for reconnect handling. diff --git a/lib/core/usp/services/usp_client.dart b/lib/core/usp/services/usp_client.dart index b64d5a3b6..ddc5e82b3 100644 --- a/lib/core/usp/services/usp_client.dart +++ b/lib/core/usp/services/usp_client.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:privacy_gui/core/utils/logger.dart'; @@ -91,11 +92,21 @@ class UspClient { _client = UspClientWeb.fromJsClient(jsClient); } - static int _reqId = 0; + static final _random = Random(); static const _tag = '[USPClient]:'; bool _lastCallRetried = false; - String _idLabel(int id) => '#$id${_lastCallRetried ? '.retry' : ''}'; + /// Generates a unique request ID: LNU{HEX-MS-TIMESTAMP}{4-CHAR-RANDOM} + /// e.g., LNU18F3A2B4C5D6E7F8 + static String _genReqId() { + final ts = + DateTime.now().millisecondsSinceEpoch.toRadixString(16).toUpperCase(); + final rand = + _random.nextInt(0xFFFF).toRadixString(16).padLeft(4, '0').toUpperCase(); + return 'LNU$ts$rand'; + } + + String _idLabel(String id) => '$id${_lastCallRetried ? '.retry' : ''}'; String get baseUrl => _baseUrl; @@ -142,8 +153,8 @@ class UspClient { await _client.logout(); } - Future refreshToken() async { - await _client.refreshToken(); + Future refreshToken({String? token}) async { + await _client.refreshToken(token: token); } // =========================================================================== @@ -255,18 +266,21 @@ class UspClient { } Future> _rawGet(List paths) async { - final id = ++_reqId; + final id = _genReqId(); _lastCallRetried = false; final sw = Stopwatch()..start(); - logger.d('$_tag#$id GET →\n${_prettyList(paths)}'); + logger.d('$_tag $_separator\n' + '$_tag #$id GET (Request) → ${paths.length} paths\n' + '${_prettyList(paths)}'); try { final rawMap = await _withAuthRetry(() => _client.get(paths)); sw.stop(); final label = _idLabel(id); - logger.d('$_tag$label GET ← (${sw.elapsedMilliseconds}ms)\n' + logger.d('$_tag $_separator\n' + '$_tag $label GET (Response) ← ${sw.elapsedMilliseconds}ms\n' '${_prettyMap(rawMap)}'); if (rawMap.isEmpty) { @@ -346,7 +360,7 @@ class UspClient { } Future> _singleSet(String path, String value) async { - final id = ++_reqId; + final id = _genReqId(); _lastCallRetried = false; final sw = Stopwatch()..start(); final params = {path: value}; @@ -370,7 +384,7 @@ class UspClient { Future> _batchSet(Map parameters, {bool allowPartial = false}) async { - final id = ++_reqId; + final id = _genReqId(); _lastCallRetried = false; final sw = Stopwatch()..start(); final Map stringParams = @@ -410,7 +424,7 @@ class UspClient { Future> setOrdered( List>> parameterGroups, {bool allowPartial = false}) async { - final id = ++_reqId; + final id = _genReqId(); _lastCallRetried = false; final sw = Stopwatch()..start(); @@ -456,7 +470,7 @@ class UspClient { Future> _singleAdd( String objectPath, Map parameters) async { - final id = ++_reqId; + final id = _genReqId(); _lastCallRetried = false; final sw = Stopwatch()..start(); final stringParams = parameters.map((k, v) => MapEntry(k, v.toString())); @@ -483,7 +497,7 @@ class UspClient { Future> _batchAdd(List> objects, {bool allowPartial = false}) async { - final id = ++_reqId; + final id = _genReqId(); _lastCallRetried = false; final sw = Stopwatch()..start(); @@ -523,7 +537,7 @@ class UspClient { } Future> _singleDelete(String path) async { - final id = ++_reqId; + final id = _genReqId(); _lastCallRetried = false; final sw = Stopwatch()..start(); @@ -546,7 +560,7 @@ class UspClient { Future> _batchDelete(List paths, {bool allowPartial = false}) async { - final id = ++_reqId; + final id = _genReqId(); _lastCallRetried = false; final sw = Stopwatch()..start(); @@ -582,7 +596,7 @@ class UspClient { /// all output arguments from the Operate response. Future> operate(String command, {Map args = const {}}) async { - final id = ++_reqId; + final id = _genReqId(); _lastCallRetried = false; final sw = Stopwatch()..start(); @@ -660,7 +674,7 @@ class UspClient { required String notifType, required String referenceList, }) async { - final id = ++_reqId; + final id = _genReqId(); final sw = Stopwatch()..start(); const objectPath = 'Device.LocalAgent.Subscription.'; @@ -746,7 +760,7 @@ class UspClient { /// Deletes an OBUSPA subscription instance. Future deleteNotifySubscription(String instancePath) async { - final id = ++_reqId; + final id = _genReqId(); final sw = Stopwatch()..start(); await _withAuthRetry(() => _client.delete([instancePath])); sw.stop(); @@ -762,7 +776,7 @@ class UspClient { /// Returns raw subscription objects from the router. Each entry typically /// contains fields like `instance_path`, `notif_type`, `reference_list`, etc. Future>> listSubscriptions() async { - final id = ++_reqId; + final id = _genReqId(); final sw = Stopwatch()..start(); final subs = await _withAuthRetry(() => _client.listSubscriptions()); sw.stop(); @@ -783,7 +797,7 @@ class UspClient { /// /// Returns the number of subscriptions deleted. Future purgeAllSubscriptions() async { - final id = ++_reqId; + final id = _genReqId(); final sw = Stopwatch()..start(); const objectPath = 'Device.LocalAgent.Subscription.'; @@ -982,6 +996,7 @@ class UspClient { // Log helpers // =========================================================================== + static const _separator = '════════════════════════════════════════'; static const _jsonEncoder = JsonEncoder.withIndent(' '); static String _prettyList(List list) { diff --git a/lib/core/usp/stub/usp_client_stub.dart b/lib/core/usp/stub/usp_client_stub.dart index e2c36de48..2b987093d 100644 --- a/lib/core/usp/stub/usp_client_stub.dart +++ b/lib/core/usp/stub/usp_client_stub.dart @@ -24,7 +24,7 @@ class UspClientWeb { Future logout() => throw UnsupportedError('USP is only available on Web'); - Future refreshToken() => + Future refreshToken({String? token}) => throw UnsupportedError('USP is only available on Web'); Future> get(List paths) => diff --git a/lib/core/usp/web/usp_client_wasm.dart b/lib/core/usp/web/usp_client_wasm.dart index f92d7e544..bc0f27844 100644 --- a/lib/core/usp/web/usp_client_wasm.dart +++ b/lib/core/usp/web/usp_client_wasm.dart @@ -73,7 +73,7 @@ extension type UspClientJS._(JSObject _) implements JSObject { external JSPromise logout(); - external JSPromise refreshToken(); + external JSPromise refreshToken(String? token); // Unified set: accepts parameters object {path: value, ...} + optional options @JS('set') @@ -151,8 +151,8 @@ class UspClientWeb { await _client.logout().toDart; } - Future refreshToken() async { - await _client.refreshToken().toDart; + Future refreshToken({String? token}) async { + await _client.refreshToken(token).toDart; } // --------------------------------------------------------------------------- diff --git a/lib/core/utils/ipv6_address.dart b/lib/core/utils/ipv6_address.dart new file mode 100644 index 000000000..8e2f5b136 --- /dev/null +++ b/lib/core/utils/ipv6_address.dart @@ -0,0 +1,139 @@ +/// IPv6 address classification and ordering utilities. +/// +/// Routers commonly expose several IPv6 addresses per interface (e.g. a +/// link-local `fe80::/10`, a Unique Local Address `fc00::/7`, and one or more +/// global unicast `2000::/3` addresses). The USP data model returns these in +/// TR-181 instance order, which is *not* preference order — the link-local +/// address is frequently instance 1. When the UI needs a single +/// "representative" address (e.g. the WAN IPv6 shown on the dashboard Network +/// Status widget), it must be the globally routable one, not the link-local. +/// +/// This helper classifies addresses by their high-order bytes (mirroring the +/// scheme used by [Ipv6Rule] in `validator_rules/rules.dart`) and provides an +/// ordering that surfaces global unicast addresses first. +library; + +import 'package:privacy_gui/core/utils/ipv6_ranges.dart'; + +/// IPv6 address scope categories, ordered by display preference +/// (lower [preference] wins). +enum Ipv6Scope { + /// Global unicast (`2000::/3`) — routable on the public internet. + global(0), + + /// Unique Local Address (`fc00::/7`) — routable within a site/organization. + uniqueLocal(1), + + /// Link-local (`fe80::/10`) — only valid on the local link, not routable. + linkLocal(2), + + /// Anything else (loopback, unspecified, multicast, unparseable, …). + other(3); + + const Ipv6Scope(this.preference); + + /// Sort key — lower values are preferred for display. + final int preference; +} + +/// Classifies an IPv6 [address] string into an [Ipv6Scope]. +/// +/// Only the first two bytes are needed to distinguish global / ULA / +/// link-local, so a full parse is avoided. Returns [Ipv6Scope.other] for +/// addresses that cannot be classified (empty, IPv4, malformed). +Ipv6Scope classifyIpv6Scope(String address) { + final bytes = _firstTwoBytes(address); + if (bytes == null) return Ipv6Scope.other; + + final firstByte = bytes[0]; + final secondByte = bytes[1]; + + // Deprecated / reserved ranges that fall inside 2000::/3 by first byte must + // be excluded before the global-unicast test, matching IPv6WithReservedRule: + // * 3FFE::/16 — 6bone deprecated testing network (RFC 3701). + // * 5F00::/12 and 6000::/3–7FFF::/3 — reserved/unallocated. + if (is6boneBytes(firstByte, secondByte) || isReservedGlobalByte(firstByte)) { + return Ipv6Scope.other; + } + + // Global unicast: 2000::/3 (first byte 0x20–0x3F). + if (isGlobalUnicastByte(firstByte)) return Ipv6Scope.global; + + // Link-local: fe80::/10 (first byte 0xFE, top two bits of second byte = 10). + if (isLinkLocalBytes(firstByte, secondByte)) return Ipv6Scope.linkLocal; + + // Unique Local Address: fc00::/7 (first byte 0xFC or 0xFD). + if (isUniqueLocalByte(firstByte)) return Ipv6Scope.uniqueLocal; + + return Ipv6Scope.other; +} + +/// Whether [address] is a globally routable (global unicast) IPv6 address. +bool isGlobalUnicastIpv6(String address) => + classifyIpv6Scope(address) == Ipv6Scope.global; + +/// Whether [address] is an IPv6 link-local address (`fe80::/10`). +/// +/// Link-local addresses are only valid on a single link and are never a +/// meaningful address to surface in the UI. The single source of truth for the +/// `fe80::/10` range lives in `ipv6_ranges.dart`; this mirrors the public shape +/// of [isGlobalUnicastIpv6] so callers can filter without re-implementing the +/// scope test. +bool isLinkLocalIpv6(String address) => + classifyIpv6Scope(address) == Ipv6Scope.linkLocal; + +/// Returns [addresses] reordered so the most routable address comes first: +/// global unicast, then ULA, then link-local, then anything else. The relative +/// order of addresses that share a scope is preserved (stable sort), so the +/// original TR-181 instance order still acts as a tie-breaker. +List preferGlobalIpv6First(Iterable addresses) { + final list = addresses.toList(); + // List.sort is not guaranteed stable, so decorate with the original index. + final indexed = >[ + for (var i = 0; i < list.length; i++) MapEntry(i, list[i]), + ]; + indexed.sort((a, b) { + final byScope = classifyIpv6Scope(a.value) + .preference + .compareTo(classifyIpv6Scope(b.value).preference); + if (byScope != 0) return byScope; + return a.key.compareTo(b.key); // stable tie-break on original position + }); + return [for (final e in indexed) e.value]; +} + +/// Parses just the first two bytes of an IPv6 [address]. +/// +/// Handles `::` zero-compression and rejects obviously invalid input. Returns +/// `null` when the address cannot be parsed into at least one hextet. +List? _firstTwoBytes(String address) { + final trimmed = address.trim(); + if (trimmed.isEmpty) return null; + + // Strip a zone id / scope suffix (e.g. "fe80::1%eth0") and any prefix length. + var s = trimmed.split('%').first.split('/').first; + if (s.isEmpty) return null; + + // Reject anything that is not hex digits or colons (e.g. IPv4). + if (!RegExp(r'^[0-9a-fA-F:]+$').hasMatch(s)) return null; + + // Only one '::' is allowed. + if (s.indexOf('::') != s.lastIndexOf('::')) return null; + + // The first hextet is what determines the scope. Take the substring up to + // the first ':' (or '::'); an address beginning with '::' has a zero first + // hextet. + String firstHextet; + if (s.startsWith('::')) { + firstHextet = '0'; + } else { + final colon = s.indexOf(':'); + firstHextet = colon == -1 ? s : s.substring(0, colon); + if (firstHextet.isEmpty) firstHextet = '0'; + } + + final value = int.tryParse(firstHextet, radix: 16); + if (value == null || value < 0 || value > 0xFFFF) return null; + + return [(value >> 8) & 0xFF, value & 0xFF]; +} diff --git a/lib/core/utils/ipv6_ranges.dart b/lib/core/utils/ipv6_ranges.dart new file mode 100644 index 000000000..f785ee621 --- /dev/null +++ b/lib/core/utils/ipv6_ranges.dart @@ -0,0 +1,36 @@ +/// Single source of truth for IPv6 high-order byte-range classification. +/// +/// Two independent call sites classify IPv6 addresses by their first two +/// bytes and had drifted into duplicated magic constants: +/// * [classifyIpv6Scope] in `core/utils/ipv6_address.dart` (display ordering) +/// * [IPv6WithReservedRule] in `validator_rules/rules.dart` (input validation) +/// +/// The predicates below are the shared definition of those ranges so the two +/// callers cannot diverge. Each takes already-parsed bytes (0–0xFF) — parsing +/// remains the caller's responsibility. +library; + +/// Global unicast — `2000::/3` (first byte `0x20`–`0x3F`). +bool isGlobalUnicastByte(int firstByte) => + firstByte >= 0x20 && firstByte <= 0x3F; + +/// Link-local — `fe80::/10` (first byte `0xFE`, top two bits of the second +/// byte are `10`). +bool isLinkLocalBytes(int firstByte, int secondByte) => + firstByte == 0xFE && (secondByte & 0xC0) == 0x80; + +/// Unique Local Address — `fc00::/7` (first byte `0xFC` or `0xFD`). +bool isUniqueLocalByte(int firstByte) => firstByte == 0xFC || firstByte == 0xFD; + +/// 6bone deprecated IPv6 testing network — `3FFE::/16` (RFC 3701). This falls +/// inside the `2000::/3` global-unicast range by first byte, so it must be +/// excluded explicitly before applying [isGlobalUnicastByte]. +bool is6boneBytes(int firstByte, int secondByte) => + firstByte == 0x3F && secondByte == 0xFE; + +/// Reserved / unallocated space adjacent to the global-unicast range +/// (e.g. `5F00::/12`, `6000::/3`–`7FFF::/3`) — first byte `0x5F`–`0x7F`. +/// These already fall outside [isGlobalUnicastByte]; the predicate exists so +/// validators that must reject them share one definition. +bool isReservedGlobalByte(int firstByte) => + firstByte >= 0x5F && firstByte <= 0x7F; diff --git a/lib/core/utils/logger.dart b/lib/core/utils/logger.dart index 9ae375167..3226d0b5c 100644 --- a/lib/core/utils/logger.dart +++ b/lib/core/utils/logger.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:io'; import 'package:collection/collection.dart'; import 'package:device_info_plus/device_info_plus.dart'; @@ -11,11 +12,11 @@ import 'package:package_info_plus/package_info_plus.dart'; /// A global logger instance for application-wide logging. /// -/// This logger is configured with a `ProductionFilter` to control log output +/// This logger is configured with a [_AppLogFilter] to control log output /// based on the build mode, a `SimplePrinter` for formatting, and a /// [CustomOutput] for handling log persistence. final logger = Logger( - filter: ProductionFilter(), + filter: _AppLogFilter(), printer: SimplePrinter( printTime: true, colors: kIsWeb ? false : stdout.supportsAnsiEscapes, @@ -23,6 +24,23 @@ final logger = Logger( output: CustomOutput(), ); +/// Custom log filter that adjusts minimum log level based on build mode. +/// +/// - Debug/Profile mode: all levels including [Level.trace] +/// - Release mode: [Level.debug] and above (filters out trace) +/// +/// Use [logger.t()] for verbose development logs (App build, Throttler dispatch, +/// internal WiFi/Topology details) that should not appear in production. +class _AppLogFilter extends LogFilter { + @override + bool shouldLog(LogEvent event) { + final minLevel = kReleaseMode + ? Level.debug + : Level.trace; // ignore: prefer_const_declarations + return event.level.value >= minLevel.value; + } +} + /// A custom log output handler that writes logs to the console, files, or web storage. /// /// In debug mode, all logs are printed to the console. @@ -108,19 +126,74 @@ const routeLogTag = 'RouteChanged'; /// [log] The log message string to record. /// [level] The severity level of this log entry. void _recordLog(String log, Level level) async { - // Add every log message to the 'app' log list + // Add every log message to the 'app' log list (flat/original format) _addLogWithTag(message: log, level: level); // If a custom tag is specified, add to its log list final record = _splitTagAndMessage(log); if (record != null) { - _addLogWithTag(message: record.$1, tag: record.$2, level: level); + // Transform USPClient Response JSON to nested format for tag log + final transformedMessage = _transformForTagLog(record.$1, record.$2); + _addLogWithTag(message: transformedMessage, tag: record.$2, level: level); + } +} + +/// Transforms log message for tag-specific display. +/// USPClient Response logs get their flat JSON converted to nested structure. +String _transformForTagLog(String message, String tag) { + if (tag != 'USPClient') return message; + if (!message.contains('(Response)')) return message; + + // Find JSON object in the message + final jsonStart = message.indexOf('{'); + final jsonEnd = message.lastIndexOf('}'); + if (jsonStart == -1 || jsonEnd == -1 || jsonEnd <= jsonStart) return message; + + try { + final jsonStr = message.substring(jsonStart, jsonEnd + 1); + final flatMap = jsonDecode(jsonStr) as Map; + final nestedMap = _toNestedStructure(flatMap); + final nestedJson = const JsonEncoder.withIndent(' ') + .convert(nestedMap) + .replaceAll('\n', '\n '); + + return '${message.substring(0, jsonStart)}$nestedJson'; + } catch (_) { + return message; + } +} + +/// Converts flat TR-181 paths to nested structure. +Map _toNestedStructure(Map flatMap) { + final result = {}; + for (final entry in flatMap.entries) { + final segments = entry.key.split('.'); + _setNestedValue(result, segments, entry.value); } + return result; +} + +void _setNestedValue( + Map root, List segments, dynamic value) { + var current = root; + for (var i = 0; i < segments.length - 1; i++) { + final segment = segments[i]; + if (!current.containsKey(segment)) { + current[segment] = {}; + } + final next = current[segment]; + if (next is Map) { + current = next; + } else { + current[segment] = {}; + current = current[segment] as Map; + } + } + current[segments.last] = value; } /// Adds a log message to the cache under a specific tag, managing size limits. /// -/// If the `tag` is 'State', the message is parsed to update the [stateLogCache]. -/// Otherwise, the message is added to the corresponding list in [_webLogCache], +/// The message is added to the corresponding list in [_webLogCache], /// removing the oldest entry if the list exceeds its maximum size. /// /// [message] The log message to add. @@ -134,18 +207,11 @@ void _addLogWithTag( final maxSize = tag == routeLogTag ? _maxLogSizeOfRouteTag : _maxLogSizeOfGeneralTag; - if (tag == 'State') { - final stateMessage = _splitTagAndMessage(message); - if (stateMessage != null) { - _stateLogCache[stateMessage.$2] = stateMessage.$1; - } - } else { - if (logList.length + 1 > maxSize) { - logList.removeAt(0); - } - logList.add((DateTime.now().millisecondsSinceEpoch, message, level)); - _webLogCache[tag] = logList; + if (logList.length + 1 > maxSize) { + logList.removeAt(0); } + logList.add((DateTime.now().millisecondsSinceEpoch, message, level)); + _webLogCache[tag] = logList; } /// Splits a formatted log string into its message and tag components. @@ -204,6 +270,23 @@ String _levelPrefix(Level level) { /// PDF reports or other diagnostics output. String getWebLogByTag({String tag = appLogTag}) => _getWebLogByTag(tag: tag); +/// Updates the state log cache for a specific provider. +/// +/// Used by [StateLogObserver] to record the latest state of each provider. +/// Only the most recent state is kept per provider name. +void updateStateLog(String providerName, String state) { + _stateLogCache[providerName] = state; +} + +/// Read-only view of the state log cache for testing. +@visibleForTesting +Map get stateLogCacheForTest => + Map.unmodifiable(_stateLogCache); + +/// Clears the state log cache. For testing only. +@visibleForTesting +void clearStateLogCacheForTest() => _stateLogCache.clear(); + /// Compiles a full diagnostic log report as a single string. /// /// This function is intended for debugging and support purposes. It gathers diff --git a/lib/core/utils/state_log_observer.dart b/lib/core/utils/state_log_observer.dart new file mode 100644 index 000000000..b25621ada --- /dev/null +++ b/lib/core/utils/state_log_observer.dart @@ -0,0 +1,49 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacy_gui/core/utils/logger.dart'; +import 'package:privacy_gui/framework/diagnostic_loggable.dart'; +import 'package:privacy_gui/util/masking_utils.dart'; + +/// A [ProviderObserver] that records the latest state of watched providers +/// into the state log cache for diagnostics. +/// +/// Only tracks state types that implement [DiagnosticLoggable] with +/// [DiagnosticLoggable.loggable] set to `true`. +/// Each provider keeps only its most recent state (overwrites on update). +/// +/// States are NOT written to the main log stream — they are only captured +/// in [_stateLogCache] and included when the user downloads the diagnostic +/// report via [outputFullWebLog]. +class StateLogObserver extends ProviderObserver { + @override + void didUpdateProvider( + ProviderBase provider, + Object? previousValue, + Object? newValue, + ProviderContainer container, + ) { + // Extract value from AsyncValue or use directly for sync providers + Object? value; + if (newValue is AsyncValue) { + if (!newValue.hasValue) return; + value = newValue.value; + } else { + value = newValue; + } + + if (value == null) return; + + // Only capture types that implement DiagnosticLoggable with loggable=true + if (value is! DiagnosticLoggable) return; + if (!value.loggable) return; + + final typeName = value.diagnosticName; + final jsonState = value.toString(); + + // Apply masking before caching — diagnostic reports are user-downloadable + final maskedState = MaskingUtils.maskSensitiveJsonValues( + MaskingUtils.maskSerialNumber(MaskingUtils.maskMacAddress(jsonState))); + + // Update cache — will be included in diagnostic report on download + updateStateLog(typeName, maskedState); + } +} diff --git a/lib/core/utils/tr181_path.dart b/lib/core/utils/tr181_path.dart new file mode 100644 index 000000000..06545306f --- /dev/null +++ b/lib/core/utils/tr181_path.dart @@ -0,0 +1,10 @@ +// Shared helpers for TR-181 object path manipulation. + +/// Ensures a TR-181 instance path ends with a dot so paths from different +/// sources (codegen `instancePath`, `lowerLayers`, `ssidReference`, provider +/// maps) compare and look up consistently. Returns [path] unchanged when empty +/// or already dot-terminated. +String ensureTrailingDot(String path) { + if (path.isEmpty) return path; + return path.endsWith('.') ? path : '$path.'; +} diff --git a/lib/core/utils/wifi_channel.dart b/lib/core/utils/wifi_channel.dart new file mode 100644 index 000000000..ea5febc88 --- /dev/null +++ b/lib/core/utils/wifi_channel.dart @@ -0,0 +1,67 @@ +// Shared WiFi channel helpers: TR-181 PossibleChannels parsing and DFS +// (IEEE 802.11h) channel classification/filtering. Pure functions with no +// Flutter or provider dependencies — safe to import from services and widgets. + +/// Parses a TR-181 `PossibleChannels` string into a sorted list of channel +/// numbers. Handles comma-separated values and range notation. +/// e.g. "1-13,36,40,44,48" → [1,2,3,4,5,6,7,8,9,10,11,12,13,36,40,44,48] +List parsePossibleChannels(String raw) { + if (raw.isEmpty) return const []; + final result = []; + for (final part in raw.split(',')) { + final trimmed = part.trim(); + if (trimmed.contains('-')) { + final bounds = trimmed.split('-'); + // Skip malformed range tokens (e.g. "1-2-3"). + if (bounds.length != 2) continue; + final start = int.tryParse(bounds[0].trim()); + final end = int.tryParse(bounds[1].trim()); + if (start != null && end != null) { + // Inverted ranges (start > end) naturally yield nothing. + for (var i = start; i <= end; i++) { + result.add(i); + } + } + } else { + final ch = int.tryParse(trimmed); + if (ch != null) result.add(ch); + } + } + // Drop non-positive channels: TR-181 PossibleChannels "0" is an auto/any + // sentinel, not a real channel, and channel 0 must never reach the dropdown + // or be sent to firmware. + result.removeWhere((ch) => ch <= 0); + result.sort(); + return result; +} + +/// 5 GHz DFS channels (IEEE 802.11h): UNII-2A (52–64) + UNII-2C (100–144). +/// These are the only channels subject to Dynamic Frequency Selection; 2.4 GHz +/// and 6 GHz channels are never DFS. +/// +/// Regulatory scope: this is the US/FCC (UNII-2A/2C) set. Other domains differ +/// (e.g. ETSI weather-radar restrictions, MIC/Japan assignments) — extend or +/// parameterize per regulatory domain if multi-market support is required. +const Set dfsChannels5GHz = { + 52, 56, 60, 64, // + 100, 104, 108, 112, 116, 120, 124, 128, 132, 136, 140, 144, +}; + +/// True only for 5 GHz DFS channels. [band] is the normalized band string +/// ("2.4GHz" / "5GHz" / "6GHz"). +bool isDfsChannel(int channel, {required String band}) => + band == '5GHz' && dfsChannels5GHz.contains(channel); + +/// Removes DFS channels when DFS is disabled. Returns [channels] unchanged when +/// DFS is enabled or when the band is not 5 GHz (DFS applies only to 5 GHz). +/// +/// The firmware does not trim `PossibleChannels` by DFS state, and TR-181 +/// exposes no DFS-vs-non-DFS channel field, so the client filters here. +List filterDfsChannels( + List channels, { + required String band, + required bool dfsEnabled, +}) => + (dfsEnabled || band != '5GHz') + ? channels + : channels.where((ch) => !dfsChannels5GHz.contains(ch)).toList(); diff --git a/lib/demo/providers/demo_overrides.dart b/lib/demo/providers/demo_overrides.dart index f9b5ccc21..43c7b865e 100644 --- a/lib/demo/providers/demo_overrides.dart +++ b/lib/demo/providers/demo_overrides.dart @@ -6,7 +6,6 @@ library; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:privacy_gui/core/cloud/providers/geolocation/geolocation_provider.dart'; import 'package:privacy_gui/core/cloud/providers/geolocation/geolocation_state.dart'; import 'package:privacy_gui/demo/usp/demo_usp_data_loader.dart'; @@ -15,6 +14,7 @@ import 'package:privacy_gui/providers/auth/auth_provider.dart'; import 'package:privacy_gui/route/router_provider.dart'; import 'package:privacy_gui/core/usp/providers/sse_providers.dart'; import 'package:privacy_gui/core/usp/providers/usp_auth_coordinator.dart'; +import 'package:privacy_gui/core/usp/providers/usp_token_storage.dart'; import 'package:privacy_gui/core/usp/providers/usp_client_provider.dart'; import 'package:privacy_gui/page/dashboard/providers/package_widget_loader.dart'; import 'demo_router_provider.dart'; @@ -56,7 +56,7 @@ class DemoProviders { // 8. USP Auth Coordinator: Uses DemoUspClient (always authenticated) uspAuthCoordinatorProvider.overrideWith( - (ref) => UspAuthCoordinator(demoUsp, const FlutterSecureStorage())), + (ref) => UspAuthCoordinator(demoUsp, UspTokenStorage())), // 9. Package Widget Loader: Use demo templates from assets packageWidgetLoaderProvider.overrideWith(() => DemoPackageWidgetLoader()), @@ -102,10 +102,8 @@ class _DemoAuthNotifier extends AuthNotifier { // the state assignment happens after a microtask boundary, avoiding the // "Tried to modify a provider while the widget tree was building" error // when init() is called from go_router redirect during build phase. - state = await AsyncValue.guard(() async => AuthState( - loginType: LoginType.local, - localPassword: 'demo-password', - )); + state = await AsyncValue.guard( + () async => AuthState(loginType: LoginType.local)); return state.value; } @@ -113,13 +111,9 @@ class _DemoAuthNotifier extends AuthNotifier { Future localLogin( String password, { bool guardError = true, - bool pnp = false, }) async { debugPrint('Demo: Local login called'); - state = AsyncValue.data(AuthState( - loginType: LoginType.local, - localPassword: password, - )); + state = AsyncValue.data(AuthState(loginType: LoginType.local)); } @override diff --git a/lib/demo/usp/demo_usp_service.dart b/lib/demo/usp/demo_usp_service.dart index 7a19cd11e..3e60762c9 100644 --- a/lib/demo/usp/demo_usp_service.dart +++ b/lib/demo/usp/demo_usp_service.dart @@ -38,7 +38,7 @@ class DemoUspClient extends UspClient { Future logout() async {} @override - Future refreshToken() async {} + Future refreshToken({String? token}) async {} @override Future reauth() async {} diff --git a/lib/framework/diagnostic_loggable.dart b/lib/framework/diagnostic_loggable.dart new file mode 100644 index 000000000..fbc5f0d7a --- /dev/null +++ b/lib/framework/diagnostic_loggable.dart @@ -0,0 +1,185 @@ +import 'dart:convert'; + +import 'package:equatable/equatable.dart'; + +/// Structured-JSON diagnostic contract, decoupled from [Equatable]. +/// +/// A type mixes in [DiagnosticNamed] to expose [namedProps] — a keyed view of +/// its fields — so it renders as `{"key":value}` (not an opaque +/// `Instance of '...'`) when nested inside another loggable's JSON output. +/// +/// Use this directly for models that use `with EquatableMixin` (rather than +/// `extends Equatable`) and therefore cannot mix in [DiagnosticLoggable], which +/// is constrained `on Equatable`. Such models keep their own [props] for +/// equality and add [namedProps] purely for diagnostics — the two are +/// independent: +/// ```dart +/// class MeshNetwork with EquatableMixin, DiagnosticNamed { +/// @override +/// List get props => [master, slaves]; // equality +/// @override +/// Map get namedProps => { // diagnostics +/// 'master': master, +/// 'slaves': slaves, +/// }; +/// } +/// ``` +mixin DiagnosticNamed { + /// Stable identifier for diagnostic logging. + /// + /// Used as the cache key in [StateLogObserver] instead of [runtimeType], + /// which can be mangled in dart2js minified builds. + /// + /// Default returns [runtimeType.toString()]. Override for web-safe stability: + /// ```dart + /// @override + /// String get diagnosticName => 'MyData'; + /// ``` + String get diagnosticName => runtimeType.toString(); + + /// Named properties for JSON serialization. Keys become JSON field names. + Map get namedProps; + + @override + String toString() { + try { + return jsonEncode(_toJsonSafe(namedProps)); + } catch (e) { + return '${runtimeType.toString()}(${namedProps.entries.map((e) => '${e.key}: ${e.value}').join(', ')})'; + } + } + + /// Recursively converts values to JSON-safe representations. + Object? _toJsonSafe(Object? value) { + if (value == null || value is bool || value is num || value is String) { + return value; + } + if (value is Enum) { + return value.name; + } + if (value is DateTime) { + return value.toIso8601String(); + } + if (value is Duration) { + return value.inMilliseconds; + } + if (value is Map) { + return value.map((k, v) => MapEntry(k.toString(), _toJsonSafe(v))); + } + if (value is Iterable) { + return value.map(_toJsonSafe).toList(); + } + // Keyed JSON for anything exposing namedProps — covers both + // DiagnosticLoggable (on Equatable) and DiagnosticNamed (on EquatableMixin + // models like the MeshNetwork entities). + if (value is DiagnosticNamed) { + return _toJsonSafe(value.namedProps); + } + if (value is Equatable) { + return value.props.map(_toJsonSafe).toList(); + } + return value.toString(); + } +} + +/// Mixin that enables [Equatable] state classes to output structured JSON +/// for diagnostic logging. +/// +/// Classes using this mixin must define [namedProps] instead of [props]. +/// The mixin automatically derives [props] from [namedProps].values and +/// overrides [toString] to produce JSON output. +/// +/// For models built with `with EquatableMixin` (not `extends Equatable`), use +/// [DiagnosticNamed] instead — this mixin's `on Equatable` constraint cannot +/// be satisfied by them. +/// +/// Usage: +/// ```dart +/// class MyData extends Equatable with DiagnosticLoggable { +/// final String name; +/// final int count; +/// +/// const MyData({required this.name, required this.count}); +/// +/// @override +/// Map get namedProps => { +/// 'name': name, +/// 'count': count, +/// }; +/// +/// // Opt-in to state log caching (for Data providers) +/// @override +/// bool get loggable => true; +/// } +/// ``` +/// +/// The [toString] output will be: +/// ```json +/// {"name":"value","count":42} +/// ``` +mixin DiagnosticLoggable on Equatable implements DiagnosticNamed { + @override + String get diagnosticName => runtimeType.toString(); + + /// Named properties for both equality comparison and JSON serialization. + /// + /// Keys become JSON field names; values are used for both equality checks + /// (via [props]) and JSON output (via [toString]). + @override + Map get namedProps; + + /// Whether this state should be captured by [StateLogObserver]. + /// + /// Default is `true`. Override to `false` to exclude from diagnostic reports. + bool get loggable => true; + + @override + List get props => namedProps.values.toList(); + + @override + String toString() { + try { + return jsonEncode(_toJsonSafe(namedProps)); + } catch (e) { + return '${runtimeType.toString()}(${namedProps.entries.map((e) => '${e.key}: ${e.value}').join(', ')})'; + } + } + + /// Recursively converts values to JSON-safe representations. + /// + /// Mirrors [DiagnosticNamed._toJsonSafe]; kept as a separate copy because + /// [DiagnosticLoggable] is constrained `on Equatable` (not on + /// [DiagnosticNamed]) so existing `extends Equatable with DiagnosticLoggable` + /// classes need no change. + @override + Object? _toJsonSafe(Object? value) { + if (value == null || value is bool || value is num || value is String) { + return value; + } + if (value is Enum) { + return value.name; + } + if (value is DateTime) { + return value.toIso8601String(); + } + if (value is Duration) { + return value.inMilliseconds; + } + if (value is Map) { + return value.map((k, v) => MapEntry(k.toString(), _toJsonSafe(v))); + } + if (value is Iterable) { + return value.map(_toJsonSafe).toList(); + } + // Keyed JSON for anything exposing namedProps — covers both + // DiagnosticLoggable and DiagnosticNamed (EquatableMixin models like the + // MeshNetwork entities). + if (value is DiagnosticNamed) { + return _toJsonSafe(value.namedProps); + } + if (value is Equatable) { + return value.props.map(_toJsonSafe).toList(); + } + return value.toString(); + } +} diff --git a/lib/generated/gre_tunnel.g.dart b/lib/generated/gre_tunnel.g.dart new file mode 100644 index 000000000..39a00177b --- /dev/null +++ b/lib/generated/gre_tunnel.g.dart @@ -0,0 +1,64 @@ +// AUTO-GENERATED CODE - DO NOT EDIT +// This file was generated by usp-codegen +// Any modifications will be overwritten on next generation + +import 'package:privacy_gui/core/usp/services/usp_client.dart'; + +/// GRE Tunnel for PPTP WAN connections +class GreTunnel { + final String remoteEndpoints; + + const GreTunnel({ + required this.remoteEndpoints, + }); + + static const _paths = [ + 'Device.GRE.Tunnel.1.RemoteEndpoints', + ]; + + /// Fetch all parameters via USP Get message + static Future fetch(UspClient client) async { + final response = await client.get(_paths); + return GreTunnel._fromResponse(response); + } + + factory GreTunnel._fromResponse(Map response) { + final missing = []; + if (!response.containsKey('Device.GRE.Tunnel.1.RemoteEndpoints')) { + missing.add('Device.GRE.Tunnel.1.RemoteEndpoints'); + } + if (missing.isNotEmpty) { + throw 'Get failed: Validation error: Required fields missing from response: ${missing.join(", ")} (code: 9998)'; + } + return GreTunnel( + remoteEndpoints: + (response['Device.GRE.Tunnel.1.RemoteEndpoints'] ?? '') as String, + ); + } + + /// Update writable parameters via USP Set message + static Future> update( + UspClient client, { + String? remoteEndpoints, + bool allowPartial = false, + }) async { + final params = {}; + if (remoteEndpoints != null) { + params['Device.GRE.Tunnel.1.RemoteEndpoints'] = remoteEndpoints; + } + if (params.isEmpty) { + return { + 'success': true, + 'result': {'data': {}} + }; + } + return await client.set(params, allowPartial: allowPartial); + } + + @override + String toString() { + return 'GreTunnel(' + 'remoteEndpoints: $remoteEndpoints' + ')'; + } +} diff --git a/lib/generated/index.dart b/lib/generated/index.dart index 81f0e7fae..bd65b92a2 100644 --- a/lib/generated/index.dart +++ b/lib/generated/index.dart @@ -15,7 +15,9 @@ export 'firmware_images.g.dart'; export 'firmware_operations.g.dart'; export 'time_settings.g.dart'; export 'setup_state.g.dart'; +export 'l2tp_tunnel.g.dart'; export 'static_routing.g.dart'; +export 'gre_tunnel.g.dart'; export 'dhcpv4server_pools.g.dart'; export 'ethernet_interfaces.g.dart'; export 'ppp_interface.g.dart'; diff --git a/lib/generated/l2tp_tunnel.g.dart b/lib/generated/l2tp_tunnel.g.dart new file mode 100644 index 000000000..9bb19a482 --- /dev/null +++ b/lib/generated/l2tp_tunnel.g.dart @@ -0,0 +1,64 @@ +// AUTO-GENERATED CODE - DO NOT EDIT +// This file was generated by usp-codegen +// Any modifications will be overwritten on next generation + +import 'package:privacy_gui/core/usp/services/usp_client.dart'; + +/// L2TPv2 Tunnel for L2TP WAN connections +class L2tpTunnel { + final String remoteEndpoints; + + const L2tpTunnel({ + required this.remoteEndpoints, + }); + + static const _paths = [ + 'Device.L2TPv2.Tunnel.1.RemoteEndpoints', + ]; + + /// Fetch all parameters via USP Get message + static Future fetch(UspClient client) async { + final response = await client.get(_paths); + return L2tpTunnel._fromResponse(response); + } + + factory L2tpTunnel._fromResponse(Map response) { + final missing = []; + if (!response.containsKey('Device.L2TPv2.Tunnel.1.RemoteEndpoints')) { + missing.add('Device.L2TPv2.Tunnel.1.RemoteEndpoints'); + } + if (missing.isNotEmpty) { + throw 'Get failed: Validation error: Required fields missing from response: ${missing.join(", ")} (code: 9998)'; + } + return L2tpTunnel( + remoteEndpoints: + (response['Device.L2TPv2.Tunnel.1.RemoteEndpoints'] ?? '') as String, + ); + } + + /// Update writable parameters via USP Set message + static Future> update( + UspClient client, { + String? remoteEndpoints, + bool allowPartial = false, + }) async { + final params = {}; + if (remoteEndpoints != null) { + params['Device.L2TPv2.Tunnel.1.RemoteEndpoints'] = remoteEndpoints; + } + if (params.isEmpty) { + return { + 'success': true, + 'result': {'data': {}} + }; + } + return await client.set(params, allowPartial: allowPartial); + } + + @override + String toString() { + return 'L2tpTunnel(' + 'remoteEndpoints: $remoteEndpoints' + ')'; + } +} diff --git a/lib/generated/ppp_interface.g.dart b/lib/generated/ppp_interface.g.dart index 15229477a..425994bbf 100644 --- a/lib/generated/ppp_interface.g.dart +++ b/lib/generated/ppp_interface.g.dart @@ -12,6 +12,7 @@ class PppInterfaceInstance { final String pppoeServiceName; final String connectionTrigger; final int idleDisconnectTime; + final String lowerLayers; final int lcpEcho; final String connectionStatus; @@ -22,6 +23,7 @@ class PppInterfaceInstance { required this.pppoeServiceName, required this.connectionTrigger, required this.idleDisconnectTime, + required this.lowerLayers, required this.lcpEcho, required this.connectionStatus, }); @@ -35,6 +37,7 @@ class PppInterfaceInstanceUpdate { final String? pppoeServiceName; final String? connectionTrigger; final int? idleDisconnectTime; + final String? lowerLayers; const PppInterfaceInstanceUpdate({ required this.instancePath, @@ -43,6 +46,7 @@ class PppInterfaceInstanceUpdate { this.pppoeServiceName, this.connectionTrigger, this.idleDisconnectTime, + this.lowerLayers, }); } @@ -58,6 +62,7 @@ class PppInterface { 'Device.PPP.Interface.*.PPPoE.ServiceName', 'Device.PPP.Interface.*.ConnectionTrigger', 'Device.PPP.Interface.*.IdleDisconnectTime', + 'Device.PPP.Interface.*.LowerLayers', 'Device.PPP.Interface.*.LCPEcho', 'Device.PPP.Interface.*.ConnectionStatus', ]; @@ -89,6 +94,7 @@ class PppInterface { response['${p}PPPoE.ServiceName'], response['${p}ConnectionTrigger'], response['${p}IdleDisconnectTime'], + response['${p}LowerLayers'], response['${p}LCPEcho'], response['${p}ConnectionStatus'] ].every((v) => @@ -116,6 +122,9 @@ class PppInterface { if (!response.containsKey('${p}IdleDisconnectTime')) { missing.add('${p}IdleDisconnectTime'); } + if (!response.containsKey('${p}LowerLayers')) { + missing.add('${p}LowerLayers'); + } if (!response.containsKey('${p}LCPEcho')) { missing.add('${p}LCPEcho'); } @@ -134,6 +143,7 @@ class PppInterface { idleDisconnectTime: int.tryParse( response['${p}IdleDisconnectTime']?.toString() ?? '') ?? 0, + lowerLayers: (response['${p}LowerLayers'] ?? '') as String, lcpEcho: int.tryParse(response['${p}LCPEcho']?.toString() ?? '') ?? 0, connectionStatus: (response['${p}ConnectionStatus'] ?? '') as String, )); @@ -165,6 +175,9 @@ class PppInterface { params['${update.instancePath}IdleDisconnectTime'] = update.idleDisconnectTime; } + if (update.lowerLayers != null) { + params['${update.instancePath}LowerLayers'] = update.lowerLayers; + } } if (params.isEmpty) { return { diff --git a/lib/generated/wi_fi_access_points.g.dart b/lib/generated/wi_fi_access_points.g.dart index a61e09b1c..975cccfb7 100644 --- a/lib/generated/wi_fi_access_points.g.dart +++ b/lib/generated/wi_fi_access_points.g.dart @@ -34,12 +34,14 @@ class WiFiAccessPoint { /// Update descriptor for WiFiAccessPoint instances class WiFiAccessPointUpdate { final String instancePath; + final bool? enable; final String? securityModeEnabled; final String? keyPassphrase; final bool? ssidAdvertisementEnabled; const WiFiAccessPointUpdate({ required this.instancePath, + this.enable, this.securityModeEnabled, this.keyPassphrase, this.ssidAdvertisementEnabled, @@ -164,6 +166,9 @@ class WiFiAccessPoints { {bool allowPartial = false}) async { final params = {}; for (final update in updates) { + if (update.enable != null) { + params['${update.instancePath}Enable'] = update.enable; + } if (update.securityModeEnabled != null) { params['${update.instancePath}Security.ModeEnabled'] = update.securityModeEnabled; diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 384ed8797..49a9c7f55 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -126,6 +126,8 @@ "invalidDestinationMacAddress": "عنوان MAC الوجهة غير صالح", "invalidDns": "DNS غير صالح", "invalidGatewayIpAddress": "عنوان IP للبوابة غير صالح", + "gatewayMustBeWithinLanSubnet": "بوابة يجب أن تكون ضمن شبكة LAN الفرعية", + "gatewayMustBeOutsideLanSubnet": "بوابة يجب أن تكون خارج شبكة LAN الفرعية", "invalidInput": "إدخال غير صالح", "invalidIpAddress": "عنوان IP غير صالح", "invalidMACAddress": "عنوان MAC غير صالح.", @@ -134,6 +136,7 @@ "ipv4": "IPv4", "ipv6": "IPV6", "ipv6Address": "عنوان IPv6", + "ipv6ScopeLinkLocal": "الرابط المحلي", "keepAlive": "استمرار النشاط", "l2tpPassthrough": "عبور من خلال L2TP", "labelInterface": "الواجهة", @@ -240,9 +243,11 @@ "prefix": "بادئة", "prefixLength": "طول البادئة", "print": "طباعة", + "privateMac": "خاص", "privacyAndSecurity": "الخصوصية والأمان", "processing": "معالجة...", "protocol": "البروتوكول", + "publicMac": "عام", "quickSetup": "إعداد سريع", "applyToAllBandsDesc": "تطبيق نفس إعدادات WiFi على جميع النطاقات في وقت واحد", "rebootChildTitle": "إعادة تشغيل العقدة وجميع العقد التابعة لها", @@ -381,7 +386,7 @@ "addDeviceManually": "إضافة جهاز يدويًا", "addPortForwarding": "إضافة إعادة توجيه منفذ", "addPortRangeForwarding": "إضافة إعادة توجيه نطاق منافذ", - "addPortTriggering": "إضافة تشغيل منفذ", + "addPortTriggering": "إضافة تشغيل نطاق المنافذ", "addedWidgetNamed": "تمت إضافة {name}", "adding": "جارٍ الإضافة…", "additionalFiltersOnlineOnly": "تتوفر عوامل التصفية الإضافية للأجهزة المتصلة فقط.", @@ -435,6 +440,17 @@ "changeNetworkSettingsTitle": "تغيير إعدادات الشبكة؟", "changeRouterPassword": "تغيير كلمة مرور جهاز التوجيه", "channelNumber": "رقم القناة", + "channelAutoRecommended": "Auto (recommended)", + "channelCurrentlyUsing": "Currently using: {channel}", + "@channelCurrentlyUsing": { + "placeholders": { + "channel": { + "type": "int" + } + } + }, + "channelDfsSuffix": "· DFS", + "channelNoManualOptions": "No manual channels available for this band.", "channels": "القنوات", "checkConnectivityAndSpeed": "فحص الاتصال والسرعة", "checkForUpdates": "التحقق من التحديثات", @@ -463,6 +479,8 @@ "confirmationRequired": "التأكيد مطلوب", "confirmingNewFirmware": "جارٍ التأكد من تشغيل البرنامج الثابت الجديد…", "connect": "اتصال", + "duplicateMacAddress": "عنوان MAC هذا محجوز بالفعل", + "duplicateIpAddress": "عنوان IP هذا محجوز بالفعل", "connectedDevices": "الأجهزة المتصلة", "connecting": "جارٍ الاتصال...", "connectingToRouter": "جارٍ الاتصال بجهاز التوجيه...", @@ -547,6 +565,9 @@ "diagnosticsRecWeakWifiTitle": "إشارة WiFi ضعيفة", "disable": "تعطيل", "disableInstantPrivacyDesc": "ستتمكن جميع الأجهزة من الاتصال بشبكتك بحرية.", + "privateMacWarningTitle": "تستخدم بعض الأجهزة عنوان Wi-Fi خاصًا", + "privateMacWarningDesc": "تستخدم الأجهزة المذكورة أدناه عنوان Wi-Fi خاصًا (عشوائيًا). ونظرًا لأن هذا العنوان يتغير بمرور الوقت، فقد يتم حظرها بعد تبديله — حتى الجهاز الذي تستخدمه الآن. للبقاء متصلاً، أوقف تشغيل «عنوان Wi-Fi خاص» لهذه الأجهزة قبل تمكين الخصوصية الفورية.", + "privateMacLabel": "خاص", "disableInstantPrivacyTitle": "تعطيل الخصوصية الفورية؟", "discards": "حالات التجاهل", "distribution": "التوزيع", @@ -573,7 +594,7 @@ "dynamicFrequencySelection": "الاختيار الديناميكي للتردد (DFS)", "editPortForwarding": "تحرير إعادة توجيه المنفذ", "editPortRangeForwarding": "تحرير إعادة توجيه نطاق المنافذ", - "editPortTriggering": "تحرير تشغيل المنفذ", + "editPortTriggering": "تحرير تشغيل نطاق المنافذ", "editRule": "تحرير القاعدة", "editStaticRoute": "تحرير مسار ثابت", "editTimeSettings": "تحرير إعدادات الوقت", @@ -597,7 +618,6 @@ "errorInvalidCredentials": "اسم المستخدم أو كلمة المرور غير صحيحة.", "errorInvalidInput": "القيمة المُدخلة غير صالحة. يرجى التحقق والمحاولة مرة أخرى.", "errorInvalidSessionToken": "جلستك غير صالحة. يرجى تسجيل الدخول مرة أخرى.", - "errorLoadingSpeedTest": "خطأ في تحميل اختبار السرعة", "errorNetwork": "خطأ في الشبكة. يرجى التحقق من اتصالك والمحاولة مرة أخرى.", "errorNotAuthenticated": "لم تسجّل الدخول. يرجى تسجيل الدخول والمحاولة مرة أخرى.", "errorResourceNotFound": "تعذّر العثور على الإعداد المطلوب على جهاز التوجيه.", @@ -763,6 +783,9 @@ "noAdvancedWifiSettings": "لا توجد إعدادات WiFi متقدمة متاحة لهذا الجهاز.", "noAnswersReturned": "لم تُرجَع أي إجابات.", "noAppsInstalled": "لا توجد تطبيقات مثبتة على جهاز التوجيه هذا", + "unableToLoadApps": "تعذّر تحميل التطبيقات", + "unableToLoadHealthData": "تعذّر تحميل بيانات الحالة", + "unableToLoadTopology": "تعذّر تحميل الطوبولوجيا", "noClients": "لا يوجد عملاء", "noDeviceActivityRecorded": "لم يُسجّل أي نشاط للجهاز", "noDevicesCurrentlyConnected": "لا توجد أجهزة متصلة حاليًا.", @@ -780,7 +803,7 @@ "noLogFilesAvailable": "لا توجد ملفات سجل متاحة على جهاز التوجيه هذا", "noPortMappingsConfigured": "لم يتم تكوين تعيينات منافذ", "noPortRangeRules": "لم يتم تكوين قواعد إعادة توجيه نطاق منافذ", - "noPortTriggeringRules": "لم يتم تكوين قواعد تشغيل منافذ", + "noPortTriggeringRules": "لم يتم تكوين قواعد تشغيل نطاق المنافذ", "noPresetSelected": "لم يتم اختيار إعداد مسبق", "noReservationIpMayChange": "لا يوجد حجز. قد يتغير IP عند إعادة الاتصال.", "noSinglePortRules": "لم يتم تكوين قواعد إعادة توجيه منفذ مفرد", @@ -836,9 +859,8 @@ "portMapping": "تعيين المنافذ", "portMappingSubtitle": "قواعد إعادة توجيه المنافذ وتكوين DMZ", "portMustBe1To65535": "يجب أن يكون المنفذ من 1 إلى 65535", - "portRangeWithCount": "نطاق المنافذ ({count})", "portRules": "قواعد المنافذ", - "portTriggering": "تشغيل المنافذ", + "portTriggering": "تشغيل نطاق المنافذ", "ports": "المنافذ", "potentialIssues": "مشكلات محتملة", "pppStatus": "حالة PPP", @@ -882,6 +904,12 @@ "renew": "تجديد", "requiredLabel": "(مطلوب)", "reservationReleased": "تم تحرير الحجز", + "reservationAdded": "تمت إضافة الحجز", + "reservationDeleted": "تم حذف الحجز", + "reconnectedToRouter": "تمت إعادة الاتصال بجهاز التوجيه", + "timeSettingsSaved": "تم حفظ إعدادات الوقت", + "ruleAdded": "تمت إضافة القاعدة", + "channelUpdated": "تم تحديث القناة", "reservations": "الحجوزات", "reserveIpAddress": "حجز عنوان IP", "reserved": "محجوز", @@ -908,6 +936,8 @@ "routerRebootComplete": "اكتملت إعادة تشغيل جهاز التوجيه", "routerWritingImage": "يقوم جهاز التوجيه بكتابة الصورة الجديدة. لا تقم بإيقاف التشغيل.", "rtColumn": "RT", + "unableToLoadDiagnostics": "تعذّر تحميل التشخيص", + "unableToLoadSpeedTest": "تعذّر تحميل اختبار السرعة", "rules": "القواعد", "runAgain": "تشغيل مرة أخرى", "runDiagnosticsTitle": "تشغيل التشخيص", @@ -942,7 +972,6 @@ "signalQuality": "جودة الإشارة", "signalQualitySubtitle": "جودة إشارة WiFi حسب نطاق التردد", "signalStrengthDbm": "{value} dBm", - "singlePortWithCount": "منفذ مفرد ({count})", "singleRouterSetupNoBackhaul": "إعداد بجهاز توجيه واحد — بدون نقل خلفي", "sizeBytes": "الحجم: {size} بايت ({mib} MiB)", "skipped": "تم التخطي", @@ -1003,15 +1032,10 @@ "trend": "الاتجاه", "trends": "الاتجاهات", "triggerPorts": "منافذ التشغيل", - "triggeringWithCount": "التشغيل ({count})", "txPower": "طاقة الإرسال", "type": "النوع", "typeAMessage": "اكتب رسالة...", "unableToGatherDeviceInfo": "تعذّر جمع معلومات الجهاز", - "unableToLoadApps": "تعذّر تحميل التطبيقات", - "unableToLoadDiagnostics": "تعذّر تحميل التشخيص", - "unableToLoadSpeedTest": "تعذّر تحميل اختبار السرعة", - "unableToLoadTopology": "تعذّر تحميل الطوبولوجيا", "unchangedLabel": "(لم يتغير)", "unknownWidget": "أداة غير معروفة: {id}", "unnamed": "(بدون اسم)", @@ -1099,5 +1123,12 @@ "targetChain": "سلسلة الهدف", "synchronized": "متزامن", "noPortForwardingRulesConfigured": "لم يتم تكوين أي قواعد لإعادة توجيه المنافذ", - "nItems": "{count, plural, zero{لا عناصر} one{عنصر واحد} two{عنصران} few{{count} عناصر} many{{count} عنصرًا} other{{count} عنصر}}" + "nItems": "{count, plural, zero{لا عناصر} one{عنصر واحد} two{عنصران} few{{count} عناصر} many{{count} عنصرًا} other{{count} عنصر}}", + "bridgeReconnectHint": "بعد الحفظ، أعد الاتصال بجهاز التوجيه على {url}.", + "bridgeRedirectTitle": "إعادة الاتصال بجهاز التوجيه", + "bridgeRedirectMessage": "يعمل جهاز التوجيه الآن كجسر شفاف ولم يعد يوزّع عناوين IP محلية. أعد الاتصال على {url}.", + "bridgeRedirectButton": "الانتقال إلى جهاز التوجيه", + "lanIpRedirectTitle": "إعادة الاتصال بجهاز التوجيه", + "lanIpRedirectMessage": "لقد تغيّر عنوان IP الخاص بجهاز التوجيه. أعد الاتصال به على {url}. قد تحتاج إلى الانتظار قليلاً حتى يحصل جهازك على عنوان جديد.", + "lanIpRedirectButton": "الانتقال إلى جهاز التوجيه" } diff --git a/lib/l10n/app_da.arb b/lib/l10n/app_da.arb index 3df971451..b4a80dcc6 100644 --- a/lib/l10n/app_da.arb +++ b/lib/l10n/app_da.arb @@ -126,6 +126,8 @@ "invalidDestinationMacAddress": "Ugyldig destinations-MAC-adresse", "invalidDns": "Ugyldigt DNS", "invalidGatewayIpAddress": "Ugyldig IP-adresse på gatewayen", + "gatewayMustBeWithinLanSubnet": "Gateway skal være inden for LAN-subnet", + "gatewayMustBeOutsideLanSubnet": "Gateway skal være uden for LAN-subnet", "invalidInput": "Ugyldigt input", "invalidIpAddress": "Ugyldig IP-adresse", "invalidMACAddress": "Ugyldig MAC-adresse.", @@ -134,6 +136,7 @@ "ipv4": "IPv4", "ipv6": "IPV6", "ipv6Address": "IPv6-adresse", + "ipv6ScopeLinkLocal": "Link-lokal", "keepAlive": "Bevar tilslutningen", "l2tpPassthrough": "L2TP Passthrough", "labelInterface": "Grænseflade", @@ -239,9 +242,11 @@ "prefix": "Præfiks", "prefixLength": "Længde på præfikset", "print": "Udskriv", + "privateMac": "Privat", "privacyAndSecurity": "Databeskyttelse og sikkerhed", "processing": "Forarbejdning...", "protocol": "Protokol", + "publicMac": "Offentlig", "quickSetup": "Hurtig opsætning", "applyToAllBandsDesc": "Anvend de samme WiFi-indstillinger på alle bånd på én gang", "rebootChildTitle": "Genstart noden og alle dens underordnede", @@ -380,7 +385,7 @@ "addDeviceManually": "Tilføj enhed manuelt", "addPortForwarding": "Tilføj portvideresendelse", "addPortRangeForwarding": "Tilføj portinterval-videresendelse", - "addPortTriggering": "Tilføj portudløsning", + "addPortTriggering": "Tilføj porttriggeringsinterval", "addedWidgetNamed": "{name} tilføjet", "adding": "Tilføjer…", "additionalFiltersOnlineOnly": "Yderligere filtre er kun tilgængelige for enheder, der er online.", @@ -434,6 +439,17 @@ "changeNetworkSettingsTitle": "Skift netværksindstillinger?", "changeRouterPassword": "Skift routerens adgangskode", "channelNumber": "Kanalnummer", + "channelAutoRecommended": "Auto (recommended)", + "channelCurrentlyUsing": "Currently using: {channel}", + "@channelCurrentlyUsing": { + "placeholders": { + "channel": { + "type": "int" + } + } + }, + "channelDfsSuffix": "· DFS", + "channelNoManualOptions": "No manual channels available for this band.", "channels": "Kanaler", "checkConnectivityAndSpeed": "Tjek forbindelse og hastighed", "checkForUpdates": "Søg efter opdateringer", @@ -462,6 +478,8 @@ "confirmationRequired": "Bekræftelse påkrævet", "confirmingNewFirmware": "Bekræfter, at den nye firmware kører…", "connect": "Tilslut", + "duplicateMacAddress": "Denne MAC-adresse er allerede reserveret", + "duplicateIpAddress": "Denne IP-adresse er allerede reserveret", "connectedDevices": "Tilsluttede enheder", "connecting": "Tilslutter...", "connectingToRouter": "Tilslutter til router...", @@ -546,6 +564,9 @@ "diagnosticsRecWeakWifiTitle": "Svagt WiFi-signal", "disable": "Deaktiver", "disableInstantPrivacyDesc": "Alle enheder vil kunne oprette forbindelse frit til dit netværk.", + "privateMacWarningTitle": "Nogle enheder bruger en privat Wi-Fi-adresse", + "privateMacWarningDesc": "Enhederne nedenfor bruger en privat (tilfældig) Wi-Fi-adresse. Da denne adresse ændrer sig over tid, kan de blive blokeret, når den skifter — også den, du bruger lige nu. For at forblive forbundet skal du slå \"Privat Wi-Fi-adresse\" fra for disse enheder, før du aktiverer Instant Privacy.", + "privateMacLabel": "Privat", "disableInstantPrivacyTitle": "Deaktiver Instant Privacy?", "discards": "Forkastninger", "distribution": "Fordeling", @@ -572,7 +593,7 @@ "dynamicFrequencySelection": "Dynamic Frequency Selection (DFS)", "editPortForwarding": "Rediger portvideresendelse", "editPortRangeForwarding": "Rediger portinterval-videresendelse", - "editPortTriggering": "Rediger portudløsning", + "editPortTriggering": "Rediger porttriggeringsinterval", "editRule": "Rediger regel", "editStaticRoute": "Rediger statisk rute", "editTimeSettings": "Rediger tidsindstillinger", @@ -596,7 +617,6 @@ "errorInvalidCredentials": "Forkert brugernavn eller adgangskode.", "errorInvalidInput": "Den indtastede værdi er ikke gyldig. Tjek venligst, og prøv igen.", "errorInvalidSessionToken": "Din session er ugyldig. Log ind igen.", - "errorLoadingSpeedTest": "Fejl ved indlæsning af hastighedstest", "errorNetwork": "Netværksfejl. Tjek din forbindelse, og prøv igen.", "errorNotAuthenticated": "Du er ikke logget ind. Log ind, og prøv igen.", "errorResourceNotFound": "Den ønskede indstilling kunne ikke findes på routeren.", @@ -763,6 +783,9 @@ "noAdvancedWifiSettings": "Ingen avancerede WiFi-indstillinger tilgængelige for denne enhed.", "noAnswersReturned": "Ingen svar returneret.", "noAppsInstalled": "Ingen apps installeret på denne router", + "unableToLoadApps": "Kan ikke indlæse apps", + "unableToLoadHealthData": "Kan ikke indlæse tilstandsdata", + "unableToLoadTopology": "Kan ikke indlæse topologi", "noClients": "Ingen klienter", "noDeviceActivityRecorded": "Ingen enhedsaktivitet registreret", "noDevicesCurrentlyConnected": "Ingen enheder er tilsluttet i øjeblikket.", @@ -780,7 +803,7 @@ "noLogFilesAvailable": "Ingen logfiler tilgængelige på denne router", "noPortMappingsConfigured": "Ingen porttilknytninger konfigureret", "noPortRangeRules": "Ingen regler for portinterval-videresendelse konfigureret", - "noPortTriggeringRules": "Ingen portudløsningsregler konfigureret", + "noPortTriggeringRules": "Ingen porttriggeringsinterval-regler konfigureret", "noPresetSelected": "Ingen forudindstilling valgt", "noReservationIpMayChange": "Ingen reservation. IP kan ændres ved gentilslutning.", "noSinglePortRules": "Ingen regler for enkeltport-videresendelse konfigureret", @@ -836,9 +859,8 @@ "portMapping": "Porttilknytning", "portMappingSubtitle": "Regler for portvideresendelse og DMZ-konfiguration", "portMustBe1To65535": "Port skal være 1-65535", - "portRangeWithCount": "Portinterval ({count})", "portRules": "Portregler", - "portTriggering": "Portudløsning", + "portTriggering": "Porttriggeringsinterval", "ports": "Porte", "potentialIssues": "Potentielle problemer", "pppStatus": "PPP-status", @@ -882,6 +904,12 @@ "renew": "Forny", "requiredLabel": "(Påkrævet)", "reservationReleased": "Reservation frigivet", + "reservationAdded": "Reservation tilføjet", + "reservationDeleted": "Reservation slettet", + "reconnectedToRouter": "Forbindelse til router genoprettet", + "timeSettingsSaved": "Tidsindstillinger gemt", + "ruleAdded": "Regel tilføjet", + "channelUpdated": "Kanal opdateret", "reservations": "Reservationer", "reserveIpAddress": "Reservér IP-adresse", "reserved": "Reserveret", @@ -908,6 +936,8 @@ "routerRebootComplete": "Routerens genstart er fuldført", "routerWritingImage": "Routeren skriver det nye image. Sluk ikke.", "rtColumn": "RT", + "unableToLoadDiagnostics": "Kan ikke indlæse diagnostik", + "unableToLoadSpeedTest": "Kan ikke indlæse hastighedstest", "rules": "Regler", "runAgain": "Kør igen", "runDiagnosticsTitle": "Kør diagnostik", @@ -942,7 +972,6 @@ "signalQuality": "Signalkvalitet", "signalQualitySubtitle": "WiFi-signalkvalitet efter frekvensbånd", "signalStrengthDbm": "{value} dBm", - "singlePortWithCount": "Enkeltport ({count})", "singleRouterSetupNoBackhaul": "Opsætning med én router — ingen backhaul", "sizeBytes": "Størrelse: {size} bytes ({mib} MiB)", "skipped": "Sprunget over", @@ -1003,15 +1032,10 @@ "trend": "Tendens", "trends": "Tendenser", "triggerPorts": "Udløserporte", - "triggeringWithCount": "Udløsning ({count})", "txPower": "Tx-effekt", "type": "Type", "typeAMessage": "Skriv en besked...", "unableToGatherDeviceInfo": "Kan ikke indsamle enhedsoplysninger", - "unableToLoadApps": "Kan ikke indlæse apps", - "unableToLoadDiagnostics": "Kan ikke indlæse diagnostik", - "unableToLoadSpeedTest": "Kan ikke indlæse hastighedstest", - "unableToLoadTopology": "Kan ikke indlæse topologi", "unchangedLabel": "(Uændret)", "unknownWidget": "Ukendt widget: {id}", "unnamed": "(unavngivet)", @@ -1099,5 +1123,12 @@ "targetChain": "Målkæde", "synchronized": "Synkroniseret", "noPortForwardingRulesConfigured": "Ingen portvideresendelsesregler konfigureret", - "nItems": "{count, plural, one{{count} element} other{{count} elementer}}" + "nItems": "{count, plural, one{{count} element} other{{count} elementer}}", + "bridgeReconnectHint": "Efter du har gemt, skal du oprette forbindelse til din router igen på {url}.", + "bridgeRedirectTitle": "Opret forbindelse til din router igen", + "bridgeRedirectMessage": "Din router fungerer nu som en transparent bro og tildeler ikke længere lokale IP-adresser. Opret forbindelse igen på {url}.", + "bridgeRedirectButton": "Gå til routeren", + "lanIpRedirectTitle": "Opret forbindelse til din router igen", + "lanIpRedirectMessage": "Din routers IP-adresse er ændret. Opret forbindelse til den igen på {url}. Du skal muligvis vente et øjeblik, før din enhed får en ny adresse.", + "lanIpRedirectButton": "Gå til routeren" } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index f7f76e536..c481348ea 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -126,6 +126,8 @@ "invalidDestinationMacAddress": "Ungültige Ziel MAC-Adresse", "invalidDns": "Ungültiges DNS.", "invalidGatewayIpAddress": "Ungültige IP-Adresse des Gateways", + "gatewayMustBeWithinLanSubnet": "Gateway muss innerhalb des LAN-Subnetzes liegen", + "gatewayMustBeOutsideLanSubnet": "Gateway muss außerhalb des LAN-Subnetzes liegen", "invalidInput": "Ungültige Eingabe", "invalidIpAddress": "Ungültige IP-Adresse", "invalidMACAddress": "Ungültige MAC-Adresse.", @@ -134,6 +136,7 @@ "ipv4": "IPv4", "ipv6": "IPv6", "ipv6Address": "IPv6-Adresse", + "ipv6ScopeLinkLocal": "Link-lokal", "keepAlive": "Verbindung aufrecht halten", "l2tpPassthrough": "L2TP-Passthrough", "labelInterface": "Schnittstelle", @@ -239,9 +242,11 @@ "prefix": "Präfix", "prefixLength": "Länge des Präfixes", "print": "Drucken", + "privateMac": "Privat", "privacyAndSecurity": "Datenschutz und Sicherheit", "processing": "Bearbeitung...", "protocol": "Protokoll", + "publicMac": "Öffentlich", "quickSetup": "Schnelleinrichtung", "applyToAllBandsDesc": "Wenden Sie dieselben WLAN-Einstellungen gleichzeitig auf alle Bänder an", "rebootChildTitle": "Knoten und alle untergeordneten Knoten neu starten", @@ -380,7 +385,7 @@ "addDeviceManually": "Gerät manuell hinzufügen", "addPortForwarding": "Portweiterleitung hinzufügen", "addPortRangeForwarding": "Portbereich-Weiterleitung hinzufügen", - "addPortTriggering": "Port-Triggering hinzufügen", + "addPortTriggering": "Portbereich-Triggering hinzufügen", "addedWidgetNamed": "{name} hinzugefügt", "adding": "Wird hinzugefügt…", "additionalFiltersOnlineOnly": "Zusätzliche Filter sind nur für Online-Geräte verfügbar.", @@ -434,6 +439,17 @@ "changeNetworkSettingsTitle": "Netzwerkeinstellungen ändern?", "changeRouterPassword": "Router-Passwort ändern", "channelNumber": "Kanalnummer", + "channelAutoRecommended": "Auto (recommended)", + "channelCurrentlyUsing": "Currently using: {channel}", + "@channelCurrentlyUsing": { + "placeholders": { + "channel": { + "type": "int" + } + } + }, + "channelDfsSuffix": "· DFS", + "channelNoManualOptions": "No manual channels available for this band.", "channels": "Kanäle", "checkConnectivityAndSpeed": "Konnektivität und Geschwindigkeit prüfen", "checkForUpdates": "Nach Updates suchen", @@ -462,6 +478,8 @@ "confirmationRequired": "Bestätigung erforderlich", "confirmingNewFirmware": "Es wird bestätigt, dass die neue Firmware läuft…", "connect": "Verbinden", + "duplicateMacAddress": "Diese MAC-Adresse ist bereits reserviert", + "duplicateIpAddress": "Diese IP-Adresse ist bereits reserviert", "connectedDevices": "Verbundene Geräte", "connecting": "Verbindung wird hergestellt...", "connectingToRouter": "Verbindung zum Router wird hergestellt...", @@ -546,6 +564,9 @@ "diagnosticsRecWeakWifiTitle": "Schwaches WiFi-Signal", "disable": "Deaktivieren", "disableInstantPrivacyDesc": "Alle Geräte können sich frei mit Ihrem Netzwerk verbinden.", + "privateMacWarningTitle": "Einige Geräte verwenden eine private WLAN-Adresse", + "privateMacWarningDesc": "Die unten aufgeführten Geräte verwenden eine private (zufällige) WLAN-Adresse. Da sich diese Adresse im Laufe der Zeit ändert, können die Geräte nach einem Wechsel blockiert werden – auch das Gerät, das Sie gerade verwenden. Um verbunden zu bleiben, deaktivieren Sie für diese Geräte die Option „Private WLAN-Adresse“, bevor Sie Instant Privacy aktivieren.", + "privateMacLabel": "Privat", "disableInstantPrivacyTitle": "Instant Privacy deaktivieren?", "discards": "Verworfene Pakete", "distribution": "Verteilung", @@ -572,7 +593,7 @@ "dynamicFrequencySelection": "Dynamische Frequenzwahl (DFS)", "editPortForwarding": "Portweiterleitung bearbeiten", "editPortRangeForwarding": "Portbereich-Weiterleitung bearbeiten", - "editPortTriggering": "Port-Triggering bearbeiten", + "editPortTriggering": "Portbereich-Triggering bearbeiten", "editRule": "Regel bearbeiten", "editStaticRoute": "Statische Route bearbeiten", "editTimeSettings": "Zeiteinstellungen bearbeiten", @@ -596,7 +617,6 @@ "errorInvalidCredentials": "Benutzername oder Passwort ist falsch.", "errorInvalidInput": "Der eingegebene Wert ist ungültig. Bitte prüfen und erneut versuchen.", "errorInvalidSessionToken": "Ihre Sitzung ist ungültig. Bitte melden Sie sich erneut an.", - "errorLoadingSpeedTest": "Fehler beim Laden des Geschwindigkeitstests", "errorNetwork": "Netzwerkfehler. Bitte prüfen Sie Ihre Verbindung und versuchen Sie es erneut.", "errorNotAuthenticated": "Sie sind nicht angemeldet. Bitte melden Sie sich an und versuchen Sie es erneut.", "errorResourceNotFound": "Die angeforderte Einstellung konnte auf dem Router nicht gefunden werden.", @@ -763,6 +783,9 @@ "noAdvancedWifiSettings": "Für dieses Gerät sind keine erweiterten WiFi-Einstellungen verfügbar.", "noAnswersReturned": "Keine Antworten zurückgegeben.", "noAppsInstalled": "Keine Apps auf diesem Router installiert", + "unableToLoadApps": "Apps konnten nicht geladen werden", + "unableToLoadHealthData": "Zustandsdaten konnten nicht geladen werden", + "unableToLoadTopology": "Topologie konnte nicht geladen werden", "noClients": "Keine Clients", "noDeviceActivityRecorded": "Keine Geräteaktivität aufgezeichnet", "noDevicesCurrentlyConnected": "Derzeit sind keine Geräte verbunden.", @@ -780,7 +803,7 @@ "noLogFilesAvailable": "Keine Protokolldateien auf diesem Router verfügbar", "noPortMappingsConfigured": "Keine Portzuordnungen konfiguriert", "noPortRangeRules": "Keine Portbereich-Weiterleitungsregeln konfiguriert", - "noPortTriggeringRules": "Keine Port-Triggering-Regeln konfiguriert", + "noPortTriggeringRules": "Keine Portbereich-Triggering-Regeln konfiguriert", "noPresetSelected": "Keine Voreinstellung ausgewählt", "noReservationIpMayChange": "Keine Reservierung. Die IP kann sich beim erneuten Verbinden ändern.", "noSinglePortRules": "Keine Einzelport-Weiterleitungsregeln konfiguriert", @@ -836,9 +859,8 @@ "portMapping": "Portzuordnung", "portMappingSubtitle": "Portweiterleitungsregeln und DMZ-Konfiguration", "portMustBe1To65535": "Port muss zwischen 1 und 65535 liegen", - "portRangeWithCount": "Portbereich ({count})", "portRules": "Portregeln", - "portTriggering": "Port-Triggering", + "portTriggering": "Portbereich-Triggering", "ports": "Ports", "potentialIssues": "Mögliche Probleme", "pppStatus": "PPP-Status", @@ -882,6 +904,12 @@ "renew": "Erneuern", "requiredLabel": "(Erforderlich)", "reservationReleased": "Reservierung freigegeben", + "reservationAdded": "Reservierung hinzugefügt", + "reservationDeleted": "Reservierung gelöscht", + "reconnectedToRouter": "Verbindung zum Router wiederhergestellt", + "timeSettingsSaved": "Zeiteinstellungen gespeichert", + "ruleAdded": "Regel hinzugefügt", + "channelUpdated": "Kanal aktualisiert", "reservations": "Reservierungen", "reserveIpAddress": "IP-Adresse reservieren", "reserved": "Reserviert", @@ -908,6 +936,8 @@ "routerRebootComplete": "Router-Neustart abgeschlossen", "routerWritingImage": "Der Router schreibt das neue Image. Schalten Sie ihn nicht aus.", "rtColumn": "RT", + "unableToLoadDiagnostics": "Diagnose konnte nicht geladen werden", + "unableToLoadSpeedTest": "Geschwindigkeitstest konnte nicht geladen werden", "rules": "Regeln", "runAgain": "Erneut ausführen", "runDiagnosticsTitle": "Diagnose ausführen", @@ -942,7 +972,6 @@ "signalQuality": "Signalqualität", "signalQualitySubtitle": "WiFi-Signalqualität nach Frequenzband", "signalStrengthDbm": "{value} dBm", - "singlePortWithCount": "Einzelport ({count})", "singleRouterSetupNoBackhaul": "Einzelrouter-Setup – kein Backhaul", "sizeBytes": "Größe: {size} Bytes ({mib} MiB)", "skipped": "Übersprungen", @@ -1003,15 +1032,10 @@ "trend": "Trend", "trends": "Trends", "triggerPorts": "Trigger-Ports", - "triggeringWithCount": "Triggering ({count})", "txPower": "Sendeleistung", "type": "Typ", "typeAMessage": "Nachricht eingeben...", "unableToGatherDeviceInfo": "Geräteinformationen konnten nicht erfasst werden", - "unableToLoadApps": "Apps konnten nicht geladen werden", - "unableToLoadDiagnostics": "Diagnose konnte nicht geladen werden", - "unableToLoadSpeedTest": "Geschwindigkeitstest konnte nicht geladen werden", - "unableToLoadTopology": "Topologie konnte nicht geladen werden", "unchangedLabel": "(Unverändert)", "unknownWidget": "Unbekanntes Widget: {id}", "unnamed": "(unbenannt)", @@ -1099,5 +1123,12 @@ "targetChain": "Zielkette", "synchronized": "Synchronisiert", "noPortForwardingRulesConfigured": "Keine Portweiterleitungsregeln konfiguriert", - "nItems": "{count, plural, one{{count} Element} other{{count} Elemente}}" + "nItems": "{count, plural, one{{count} Element} other{{count} Elemente}}", + "bridgeReconnectHint": "Verbinden Sie sich nach dem Speichern unter {url} erneut mit Ihrem Router.", + "bridgeRedirectTitle": "Erneut mit dem Router verbinden", + "bridgeRedirectMessage": "Ihr Router arbeitet jetzt als transparente Bridge und vergibt keine lokalen IP-Adressen mehr. Verbinden Sie sich unter {url} erneut.", + "bridgeRedirectButton": "Zum Router", + "lanIpRedirectTitle": "Erneut mit dem Router verbinden", + "lanIpRedirectMessage": "Die IP-Adresse Ihres Routers hat sich geändert. Stellen Sie die Verbindung unter {url} wieder her. Möglicherweise müssen Sie einen Moment warten, bis Ihr Gerät eine neue Adresse erhält.", + "lanIpRedirectButton": "Zum Router" } diff --git a/lib/l10n/app_el.arb b/lib/l10n/app_el.arb index 1df8338ac..56a42ccd1 100644 --- a/lib/l10n/app_el.arb +++ b/lib/l10n/app_el.arb @@ -126,6 +126,8 @@ "invalidDestinationMacAddress": "Μη έγκυρη διεύθυνση MAC προορισμού", "invalidDns": "Μη έγκυρο DNS", "invalidGatewayIpAddress": "Μη έγκυρη διεύθυνση IP πύλης", + "gatewayMustBeWithinLanSubnet": "Η πύλη πρέπει να είναι εντός του υποδικτύου LAN", + "gatewayMustBeOutsideLanSubnet": "Η πύλη πρέπει να είναι εκτός του υποδικτύου LAN", "invalidInput": "Μη έγκυρη καταχώριση", "invalidIpAddress": "Μη έγκυρη διεύθυνση IP", "invalidMACAddress": "Μη έγκυρη διεύθυνση MAC.", @@ -134,6 +136,7 @@ "ipv4": "IPV4", "ipv6": "IPv6", "ipv6Address": "Διεύθυνση IPv6", + "ipv6ScopeLinkLocal": "Τοπικό ζεύξης", "keepAlive": "Διατήρηση σε ενεργή κατάσταση", "l2tpPassthrough": "Διέλευση L2TP", "labelInterface": "Διασύνδεση", @@ -237,9 +240,11 @@ "prefix": "πρόθεμα", "prefixLength": "μήκος προθέματος", "print": "Εκτύπωση", + "privateMac": "Ιδιωτική", "privacyAndSecurity": "Απόρρητο & ασφάλεια", "processing": "Επεξεργασία...", "protocol": "Πρωτόκολλο", + "publicMac": "Δημόσια", "quickSetup": "Γρήγορη ρύθμιση", "applyToAllBandsDesc": "Εφαρμόστε τις ίδιες ρυθμίσεις WiFi σε όλες τις ζώνες ταυτόχρονα", "rebootChildTitle": "Επανεκκίνηση του κόμβου και όλων των θυγατρικών του", @@ -378,7 +383,7 @@ "addDeviceManually": "Μη αυτόματη προσθήκη συσκευής", "addPortForwarding": "Προσθήκη προώθησης θύρας", "addPortRangeForwarding": "Προσθήκη προώθησης εύρους θυρών", - "addPortTriggering": "Προσθήκη ενεργοποίησης θύρας", + "addPortTriggering": "Προσθήκη ενεργοποίησης εύρους θυρών", "addedWidgetNamed": "Προστέθηκε {name}", "adding": "Προσθήκη…", "additionalFiltersOnlineOnly": "Τα πρόσθετα φίλτρα είναι διαθέσιμα μόνο για συνδεδεμένες συσκευές.", @@ -432,6 +437,17 @@ "changeNetworkSettingsTitle": "Αλλαγή ρυθμίσεων δικτύου;", "changeRouterPassword": "Αλλαγή κωδικού πρόσβασης δρομολογητή", "channelNumber": "Αριθμός καναλιού", + "channelAutoRecommended": "Auto (recommended)", + "channelCurrentlyUsing": "Currently using: {channel}", + "@channelCurrentlyUsing": { + "placeholders": { + "channel": { + "type": "int" + } + } + }, + "channelDfsSuffix": "· DFS", + "channelNoManualOptions": "No manual channels available for this band.", "channels": "Κανάλια", "checkConnectivityAndSpeed": "Έλεγχος συνδεσιμότητας και ταχύτητας", "checkForUpdates": "Έλεγχος για ενημερώσεις", @@ -460,6 +476,8 @@ "confirmationRequired": "Απαιτείται επιβεβαίωση", "confirmingNewFirmware": "Επιβεβαίωση ότι εκτελείται το νέο firmware…", "connect": "Σύνδεση", + "duplicateMacAddress": "Αυτή η διεύθυνση MAC έχει ήδη δεσμευτεί", + "duplicateIpAddress": "Αυτή η διεύθυνση IP έχει ήδη δεσμευτεί", "connectedDevices": "Συνδεδεμένες συσκευές", "connecting": "Σύνδεση...", "connectingToRouter": "Σύνδεση με τον δρομολογητή...", @@ -544,6 +562,9 @@ "diagnosticsRecWeakWifiTitle": "Αδύναμο σήμα WiFi", "disable": "Απενεργοποίηση", "disableInstantPrivacyDesc": "Όλες οι συσκευές θα μπορούν να συνδέονται ελεύθερα στο δίκτυό σας.", + "privateMacWarningTitle": "Ορισμένες συσκευές χρησιμοποιούν ιδιωτική διεύθυνση Wi-Fi", + "privateMacWarningDesc": "Οι παρακάτω συσκευές χρησιμοποιούν ιδιωτική (τυχαία) διεύθυνση Wi-Fi. Επειδή αυτή η διεύθυνση αλλάζει με την πάροδο του χρόνου, ενδέχεται να αποκλειστούν μετά την εναλλαγή της — ακόμη και αυτή που χρησιμοποιείτε τώρα. Για να παραμείνετε συνδεδεμένοι, απενεργοποιήστε την «Ιδιωτική διεύθυνση Wi-Fi» για αυτές τις συσκευές πριν ενεργοποιήσετε το Instant Privacy.", + "privateMacLabel": "Ιδιωτική", "disableInstantPrivacyTitle": "Απενεργοποίηση Instant Privacy;", "discards": "Απορρίψεις", "distribution": "Κατανομή", @@ -570,7 +591,7 @@ "dynamicFrequencySelection": "Δυναμική Επιλογή Συχνότητας (DFS)", "editPortForwarding": "Επεξεργασία προώθησης θύρας", "editPortRangeForwarding": "Επεξεργασία προώθησης εύρους θυρών", - "editPortTriggering": "Επεξεργασία ενεργοποίησης θύρας", + "editPortTriggering": "Επεξεργασία ενεργοποίησης εύρους θυρών", "editRule": "Επεξεργασία κανόνα", "editStaticRoute": "Επεξεργασία στατικής διαδρομής", "editTimeSettings": "Επεξεργασία ρυθμίσεων ώρας", @@ -594,7 +615,6 @@ "errorInvalidCredentials": "Λανθασμένο όνομα χρήστη ή κωδικός πρόσβασης.", "errorInvalidInput": "Η τιμή που εισαγάγατε δεν είναι έγκυρη. Ελέγξτε και δοκιμάστε ξανά.", "errorInvalidSessionToken": "Η συνεδρία σας δεν είναι έγκυρη. Συνδεθείτε ξανά.", - "errorLoadingSpeedTest": "Σφάλμα φόρτωσης του τεστ ταχύτητας", "errorNetwork": "Σφάλμα δικτύου. Ελέγξτε τη σύνδεσή σας και δοκιμάστε ξανά.", "errorNotAuthenticated": "Δεν έχετε συνδεθεί. Συνδεθείτε και δοκιμάστε ξανά.", "errorResourceNotFound": "Η ζητούμενη ρύθμιση δεν βρέθηκε στον δρομολογητή.", @@ -763,6 +783,9 @@ "noAdvancedWifiSettings": "Δεν υπάρχουν διαθέσιμες ρυθμίσεις WiFi για προχωρημένους για αυτήν τη συσκευή.", "noAnswersReturned": "Δεν επιστράφηκαν απαντήσεις.", "noAppsInstalled": "Δεν υπάρχουν εγκατεστημένες εφαρμογές σε αυτόν τον δρομολογητή", + "unableToLoadApps": "Δεν είναι δυνατή η φόρτωση των εφαρμογών", + "unableToLoadHealthData": "Δεν είναι δυνατή η φόρτωση των δεδομένων κατάστασης", + "unableToLoadTopology": "Δεν είναι δυνατή η φόρτωση της τοπολογίας", "noClients": "Δεν υπάρχουν πελάτες", "noDeviceActivityRecorded": "Δεν καταγράφηκε δραστηριότητα συσκευής", "noDevicesCurrentlyConnected": "Δεν υπάρχουν συσκευές συνδεδεμένες αυτή τη στιγμή.", @@ -780,7 +803,7 @@ "noLogFilesAvailable": "Δεν υπάρχουν διαθέσιμα αρχεία καταγραφής σε αυτόν τον δρομολογητή", "noPortMappingsConfigured": "Δεν έχουν διαμορφωθεί αντιστοιχίσεις θυρών", "noPortRangeRules": "Δεν έχουν διαμορφωθεί κανόνες προώθησης εύρους θυρών", - "noPortTriggeringRules": "Δεν έχουν διαμορφωθεί κανόνες ενεργοποίησης θύρας", + "noPortTriggeringRules": "Δεν έχουν διαμορφωθεί κανόνες ενεργοποίησης εύρους θυρών", "noPresetSelected": "Δεν έχει επιλεγεί προεπιλογή", "noReservationIpMayChange": "Δεν υπάρχει δέσμευση. Η IP ενδέχεται να αλλάξει κατά την επανασύνδεση.", "noSinglePortRules": "Δεν έχουν διαμορφωθεί κανόνες προώθησης μεμονωμένης θύρας", @@ -836,9 +859,8 @@ "portMapping": "Αντιστοίχιση θυρών", "portMappingSubtitle": "Κανόνες προώθησης θυρών και διαμόρφωση DMZ", "portMustBe1To65535": "Η θύρα πρέπει να είναι 1-65535", - "portRangeWithCount": "Εύρος θυρών ({count})", "portRules": "Κανόνες θυρών", - "portTriggering": "Ενεργοποίηση θύρας", + "portTriggering": "Ενεργοποίηση εύρους θυρών", "ports": "Θύρες", "potentialIssues": "Πιθανά προβλήματα", "pppStatus": "Κατάσταση PPP", @@ -882,6 +904,12 @@ "renew": "Ανανέωση", "requiredLabel": "(Υποχρεωτικό)", "reservationReleased": "Η δέσμευση αποδεσμεύτηκε", + "reservationAdded": "Η δέσμευση προστέθηκε", + "reservationDeleted": "Η δέσμευση διαγράφηκε", + "reconnectedToRouter": "Έγινε επανασύνδεση στον δρομολογητή", + "timeSettingsSaved": "Οι ρυθμίσεις ώρας αποθηκεύτηκαν", + "ruleAdded": "Ο κανόνας προστέθηκε", + "channelUpdated": "Το κανάλι ενημερώθηκε", "reservations": "Δεσμεύσεις", "reserveIpAddress": "Δέσμευση διεύθυνσης IP", "reserved": "Δεσμευμένο", @@ -908,6 +936,8 @@ "routerRebootComplete": "Η επανεκκίνηση του δρομολογητή ολοκληρώθηκε", "routerWritingImage": "Ο δρομολογητής εγγράφει τη νέα εικόνα. Μην απενεργοποιείτε τη συσκευή.", "rtColumn": "RT", + "unableToLoadDiagnostics": "Δεν είναι δυνατή η φόρτωση των διαγνωστικών", + "unableToLoadSpeedTest": "Δεν είναι δυνατή η φόρτωση του τεστ ταχύτητας", "rules": "Κανόνες", "runAgain": "Εκτέλεση ξανά", "runDiagnosticsTitle": "Εκτέλεση διαγνωστικών", @@ -942,7 +972,6 @@ "signalQuality": "Ποιότητα σήματος", "signalQualitySubtitle": "Ποιότητα σήματος WiFi ανά ζώνη συχνοτήτων", "signalStrengthDbm": "{value} dBm", - "singlePortWithCount": "Μεμονωμένη θύρα ({count})", "singleRouterSetupNoBackhaul": "Εγκατάσταση μεμονωμένου δρομολογητή — χωρίς backhaul", "sizeBytes": "Μέγεθος: {size} byte ({mib} MiB)", "skipped": "Παραλείφθηκε", @@ -1003,15 +1032,10 @@ "trend": "Τάση", "trends": "Τάσεις", "triggerPorts": "Θύρες ενεργοποίησης", - "triggeringWithCount": "Ενεργοποίηση ({count})", "txPower": "Ισχύς Tx", "type": "Τύπος", "typeAMessage": "Πληκτρολογήστε ένα μήνυμα...", "unableToGatherDeviceInfo": "Δεν είναι δυνατή η συλλογή πληροφοριών συσκευής", - "unableToLoadApps": "Δεν είναι δυνατή η φόρτωση των εφαρμογών", - "unableToLoadDiagnostics": "Δεν είναι δυνατή η φόρτωση των διαγνωστικών", - "unableToLoadSpeedTest": "Δεν είναι δυνατή η φόρτωση του τεστ ταχύτητας", - "unableToLoadTopology": "Δεν είναι δυνατή η φόρτωση της τοπολογίας", "unchangedLabel": "(Αμετάβλητο)", "unknownWidget": "Άγνωστο widget: {id}", "unnamed": "(χωρίς όνομα)", @@ -1099,5 +1123,12 @@ "targetChain": "Αλυσίδα προορισμού", "synchronized": "Συγχρονισμένο", "noPortForwardingRulesConfigured": "Δεν έχουν διαμορφωθεί κανόνες προώθησης θυρών", - "nItems": "{count, plural, one{{count} στοιχείο} other{{count} στοιχεία}}" + "nItems": "{count, plural, one{{count} στοιχείο} other{{count} στοιχεία}}", + "bridgeReconnectHint": "Μετά την αποθήκευση, επανασυνδεθείτε στον δρομολογητή σας στη διεύθυνση {url}.", + "bridgeRedirectTitle": "Επανασύνδεση στον δρομολογητή σας", + "bridgeRedirectMessage": "Ο δρομολογητής σας λειτουργεί τώρα ως διαφανής γέφυρα και δεν εκχωρεί πλέον τοπικές διευθύνσεις IP. Επανασυνδεθείτε στη διεύθυνση {url}.", + "bridgeRedirectButton": "Μετάβαση στον δρομολογητή", + "lanIpRedirectTitle": "Επανασύνδεση στον δρομολογητή σας", + "lanIpRedirectMessage": "Η διεύθυνση IP του δρομολογητή σας άλλαξε. Επανασυνδεθείτε σε αυτόν στο {url}. Ίσως χρειαστεί να περιμένετε λίγο μέχρι η συσκευή σας να λάβει νέα διεύθυνση.", + "lanIpRedirectButton": "Μετάβαση στον δρομολογητή" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index c3c5477a1..5f8619f14 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -219,6 +219,8 @@ "invalidDestinationMacAddress": "Invalid destination MAC address", "invalidDns": "Invalid DNS", "invalidGatewayIpAddress": "Invalid gateway IP address", + "gatewayMustBeWithinLanSubnet": "Gateway must be within LAN subnet", + "gatewayMustBeOutsideLanSubnet": "Gateway must be outside LAN subnet", "invalidInput": "Invalid input", "invalidIpAddress": "Invalid IP address", "invalidMACAddress": "Invalid MAC address.", @@ -234,6 +236,7 @@ "description": "Do not translate" }, "ipv6Address": "IPv6 Address", + "ipv6ScopeLinkLocal": "Link-local", "keepAlive": "Keep alive", "l2tpPassthrough": "L2TP Passthrough", "labelInterface": "Interface", @@ -394,9 +397,11 @@ "prefix": "Prefix", "prefixLength": "Prefix length", "print": "Print", + "privateMac": "Private", "privacyAndSecurity": "Privacy & Security", "processing": "Processing...", "protocol": "Protocol", + "publicMac": "Public", "rebootChildTitle": "Reboot the node and all its child/s", "rebootOk": "Yes, Reboot All", "rebootOkSingle": "Yes, Reboot", @@ -703,6 +708,7 @@ "factoryResetInProgress": "Factory reset in progress", "factoryResetWaitMessage": "The router is restoring to factory defaults. You will need to set up and log in again.", "timezoneUpdated": "Timezone updated", + "timeSettingsSaved": "Time settings saved", "portFwd": "Port Fwd", "portForwarding": "Port Forwarding", "portForwardingWithCount": "Port Forwarding ({count})", @@ -867,6 +873,8 @@ "internalPortRequired": "Internal port is required", "invalidIpv4Format": "Invalid IPv4 address format", "reservedIpNotAllowed": "Reserved IP address is not allowed", + "duplicateMacAddress": "This MAC address is already reserved", + "duplicateIpAddress": "This IP address is already reserved", "connectedDevices": "Connected Devices", "deviceNotFound": "Device not found", "networkConnections": "Network Connections ({count})", @@ -1041,7 +1049,6 @@ "rtColumn": "RT", "unableToLoadDiagnostics": "Unable to load diagnostics", "unableToLoadSpeedTest": "Unable to load speed test", - "errorLoadingSpeedTest": "Error loading speed test", "internetSpeedTest": "Internet Speed Test", "testConnectionSpeedFromRouter": "Test your connection speed from the router", "startTest": "Start Test", @@ -1186,10 +1193,22 @@ "radios": "Radios", "portRules": "Port Rules", "channelNumber": "Channel number", + "channelAutoRecommended": "Auto (recommended)", + "channelCurrentlyUsing": "Currently using: {channel}", + "@channelCurrentlyUsing": { + "placeholders": { + "channel": { + "type": "int" + } + } + }, + "channelDfsSuffix": "· DFS", + "channelNoManualOptions": "No manual channels available for this band.", "change": "Change", "builtInWidgets": "Built-in Widgets", "appWidgetCards": "App Widget Cards", "wifiSettingsSaved": "WiFi settings saved", + "channelUpdated": "Channel updated", "noWifiNetworksFound": "No WiFi networks found. Check router connection.", "quickSetupNotice": "Quick Setup applies the same name, password, and security mode to all bands. A password is required to save.", "noAdvancedWifiSettings": "No advanced WiFi settings available for this device.", @@ -1211,12 +1230,13 @@ "requiredLabel": "(Required)", "unchangedLabel": "(Unchanged)", "portForwardingSettingsSaved": "Port forwarding settings saved", + "ruleAdded": "Rule added", "singlePortForwarding": "Single Port Forwarding", "noSinglePortRules": "No single port forwarding rules configured", "portRangeForwarding": "Port Range Forwarding", "noPortRangeRules": "No port range forwarding rules configured", - "portTriggering": "Port Triggering", - "noPortTriggeringRules": "No port triggering rules configured", + "portTriggering": "Port Range Triggering", + "noPortTriggeringRules": "No port range triggering rules configured", "deleteRule": "Delete Rule", "deleteConfirm": "Delete \"{name}\"?", "@deleteConfirm": { @@ -1226,8 +1246,8 @@ } } }, - "editPortTriggering": "Edit Port Triggering", - "addPortTriggering": "Add Port Triggering", + "editPortTriggering": "Edit Port Range Triggering", + "addPortTriggering": "Add Port Range Triggering", "triggerPorts": "Trigger Ports", "forwardedPorts": "Forwarded Ports", "endPortOptional": "End Port (optional)", @@ -1237,30 +1257,6 @@ "externalPortEnd": "External Port End", "max32Characters": "Max 32 characters", "mustBeGreaterThanStartPort": "Must be greater than start port", - "singlePortWithCount": "Single Port ({count})", - "@singlePortWithCount": { - "placeholders": { - "count": { - "type": "int" - } - } - }, - "portRangeWithCount": "Port Range ({count})", - "@portRangeWithCount": { - "placeholders": { - "count": { - "type": "int" - } - } - }, - "triggeringWithCount": "Triggering ({count})", - "@triggeringWithCount": { - "placeholders": { - "count": { - "type": "int" - } - } - }, "hostname": "Hostname", "addressPool": "Address Pool", "poolStart": "Pool Start", @@ -1384,6 +1380,9 @@ } } }, + "privateMacWarningTitle": "Some devices use a private Wi-Fi address", + "privateMacWarningDesc": "The device(s) below use a private (randomized) Wi-Fi address. Because this address changes over time, they may be blocked after it rotates — even the one you're using now. To stay connected, turn off \"Private Wi-Fi Address\" for these devices before enabling Instant Privacy.", + "privateMacLabel": "Private", "disableInstantPrivacyTitle": "Disable Instant Privacy?", "disableInstantPrivacyDesc": "All devices will be able to connect freely to your network.", "addDeviceManually": "Add device manually", @@ -1466,6 +1465,8 @@ }, "ipAddressReserved": "IP address reserved", "reservationReleased": "Reservation released", + "reservationAdded": "Reservation added", + "reservationDeleted": "Reservation deleted", "viaNode": "via {node}", "@viaNode": { "placeholders": { @@ -1476,6 +1477,7 @@ }, "connectingToRouter": "Connecting to router...", "reconnecting": "Reconnecting...", + "reconnectedToRouter": "Reconnected to router", "realTimeConnectionLost": "Real-time connection lost", "disconnected": "Disconnected", "reconnect": "Reconnect", @@ -1602,6 +1604,9 @@ "networkTopology": "Network Topology", "newPassword": "New Password", "noAppsInstalled": "No apps installed on this router", + "unableToLoadApps": "Unable to load apps", + "unableToLoadHealthData": "Unable to load health data", + "unableToLoadTopology": "Unable to load topology", "routerPasswordRuleNoConsecutive": "No consecutive identical characters", "noDimensions": "No dimensions", "noLogFilesAvailable": "No log files available on this router", @@ -1633,8 +1638,6 @@ "badgeUser": "USER", "uspConsole": "USP Console", "uspDashboard": "USP Dashboard", - "unableToLoadApps": "Unable to load apps", - "unableToLoadTopology": "Unable to load topology", "routerPasswordRuleUpperLower": "Upper and lower case letters", "validatingSession": "Validating session...", "viewDevices": "View Devices", @@ -1676,5 +1679,36 @@ "type": "int" } } - } + }, + "bridgeReconnectHint": "After saving, reconnect to your router at {url}.", + "@bridgeReconnectHint": { + "description": "Bold inline hint shown while editing when Bridge Mode is selected. {url} is https://.local", + "placeholders": { + "url": { + "type": "Object" + } + } + }, + "bridgeRedirectTitle": "Reconnect to your router", + "bridgeRedirectMessage": "Your router is now a transparent bridge and no longer hands out local IP addresses. Reconnect to it at {url}.", + "@bridgeRedirectMessage": { + "description": "Body of the dialog shown after switching to Bridge Mode. {url} is https://.local", + "placeholders": { + "url": { + "type": "Object" + } + } + }, + "bridgeRedirectButton": "Go to router", + "lanIpRedirectTitle": "Reconnect to your router", + "lanIpRedirectMessage": "Your router's IP address has changed. Reconnect to it at {url}. You may need to wait a moment for your device to obtain a new address.", + "@lanIpRedirectMessage": { + "description": "Body of the dialog shown after changing the router LAN IP address. {url} is https://.local", + "placeholders": { + "url": { + "type": "Object" + } + } + }, + "lanIpRedirectButton": "Go to router" } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index ff4485b24..5e895f366 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -126,6 +126,8 @@ "invalidDestinationMacAddress": "Dirección MAC de destino no válida", "invalidDns": "DNS no válido", "invalidGatewayIpAddress": "Dirección IP de la puerta de enlace no válida", + "gatewayMustBeWithinLanSubnet": "La puerta de enlace debe estar dentro de la subred LAN", + "gatewayMustBeOutsideLanSubnet": "La puerta de enlace debe estar fuera de la subred LAN", "invalidInput": "Entrada no válida", "invalidIpAddress": "Dirección IP no válida", "invalidMACAddress": "Dirección MAC no válida.", @@ -134,6 +136,7 @@ "ipv4": "IPv4", "ipv6": "IPv6", "ipv6Address": "Dirección IPv6", + "ipv6ScopeLinkLocal": "Enlace local", "keepAlive": "Mantener activo", "l2tpPassthrough": "Paso a través de L2TP", "labelInterface": "Interfaz", @@ -239,9 +242,11 @@ "prefix": "Prefijo", "prefixLength": "Longitud del prefijo", "print": "Imprimir", + "privateMac": "Privada", "privacyAndSecurity": "Privacidad y Seguridad", "processing": "Procesando...", "protocol": "Protocolo", + "publicMac": "Pública", "quickSetup": "Configuración rápida", "applyToAllBandsDesc": "Aplicar la misma configuración de WiFi a todas las bandas a la vez", "rebootChildTitle": "Reiniciar el nodo y todos sus nodos secundarios", @@ -380,7 +385,7 @@ "addDeviceManually": "Añadir dispositivo manualmente", "addPortForwarding": "Añadir reenvío de puertos", "addPortRangeForwarding": "Añadir reenvío de intervalo de puertos", - "addPortTriggering": "Añadir activación de puertos", + "addPortTriggering": "Añadir activación de intervalo de puertos", "addedWidgetNamed": "{name} añadido", "adding": "Añadiendo…", "additionalFiltersOnlineOnly": "Los filtros adicionales solo están disponibles para dispositivos en línea.", @@ -434,6 +439,17 @@ "changeNetworkSettingsTitle": "¿Cambiar la configuración de red?", "changeRouterPassword": "Cambiar la contraseña del router", "channelNumber": "Número de canal", + "channelAutoRecommended": "Auto (recommended)", + "channelCurrentlyUsing": "Currently using: {channel}", + "@channelCurrentlyUsing": { + "placeholders": { + "channel": { + "type": "int" + } + } + }, + "channelDfsSuffix": "· DFS", + "channelNoManualOptions": "No manual channels available for this band.", "channels": "Canales", "checkConnectivityAndSpeed": "Comprobar la conectividad y la velocidad", "checkForUpdates": "Buscar actualizaciones", @@ -462,6 +478,8 @@ "confirmationRequired": "Confirmación requerida", "confirmingNewFirmware": "Confirmando que el nuevo firmware está en ejecución…", "connect": "Conectar", + "duplicateMacAddress": "Esta dirección MAC ya está reservada", + "duplicateIpAddress": "Esta dirección IP ya está reservada", "connectedDevices": "Dispositivos conectados", "connecting": "Conectando...", "connectingToRouter": "Conectando con el router...", @@ -546,6 +564,9 @@ "diagnosticsRecWeakWifiTitle": "Señal WiFi débil", "disable": "Desactivar", "disableInstantPrivacyDesc": "Todos los dispositivos podrán conectarse libremente a su red.", + "privateMacWarningTitle": "Algunos dispositivos usan una dirección Wi-Fi privada", + "privateMacWarningDesc": "Los dispositivos que aparecen a continuación usan una dirección Wi-Fi privada (aleatoria). Como esta dirección cambia con el tiempo, pueden quedar bloqueados cuando rote —incluso el que estás usando ahora—. Para no perder la conexión, desactiva la \"Dirección Wi-Fi privada\" en estos dispositivos antes de activar Instant-Privacidad.", + "privateMacLabel": "Privada", "disableInstantPrivacyTitle": "¿Desactivar Instant-Privacidad?", "discards": "Descartes", "distribution": "Distribución", @@ -572,7 +593,7 @@ "dynamicFrequencySelection": "Selección dinámica de frecuencia (DFS)", "editPortForwarding": "Editar reenvío de puertos", "editPortRangeForwarding": "Editar reenvío de intervalo de puertos", - "editPortTriggering": "Editar activación de puertos", + "editPortTriggering": "Editar activación de intervalo de puertos", "editRule": "Editar regla", "editStaticRoute": "Editar ruta estática", "editTimeSettings": "Editar configuración de hora", @@ -596,7 +617,6 @@ "errorInvalidCredentials": "Nombre de usuario o contraseña incorrectos.", "errorInvalidInput": "El valor introducido no es válido. Compruébelo e inténtelo de nuevo.", "errorInvalidSessionToken": "Su sesión no es válida. Inicie sesión de nuevo.", - "errorLoadingSpeedTest": "Error al cargar la prueba de velocidad", "errorNetwork": "Error de red. Compruebe su conexión e inténtelo de nuevo.", "errorNotAuthenticated": "No ha iniciado sesión. Inicie sesión e inténtelo de nuevo.", "errorResourceNotFound": "No se ha podido encontrar la configuración solicitada en el router.", @@ -763,6 +783,9 @@ "noAdvancedWifiSettings": "No hay configuración WiFi avanzada disponible para este dispositivo.", "noAnswersReturned": "No se han devuelto respuestas.", "noAppsInstalled": "No hay aplicaciones instaladas en este router", + "unableToLoadApps": "No se han podido cargar las aplicaciones", + "unableToLoadHealthData": "No se han podido cargar los datos de estado", + "unableToLoadTopology": "No se ha podido cargar la topología", "noClients": "Sin clientes", "noDeviceActivityRecorded": "No se ha registrado actividad de dispositivos", "noDevicesCurrentlyConnected": "No hay dispositivos conectados actualmente.", @@ -780,7 +803,7 @@ "noLogFilesAvailable": "No hay archivos de registro disponibles en este router", "noPortMappingsConfigured": "No hay asignaciones de puertos configuradas", "noPortRangeRules": "No hay reglas de reenvío de intervalo de puertos configuradas", - "noPortTriggeringRules": "No hay reglas de activación de puertos configuradas", + "noPortTriggeringRules": "No hay reglas de activación de intervalo de puertos configuradas", "noPresetSelected": "No hay ningún preajuste seleccionado", "noReservationIpMayChange": "Sin reserva. La IP puede cambiar al reconectarse.", "noSinglePortRules": "No hay reglas de reenvío de un solo puerto configuradas", @@ -836,9 +859,8 @@ "portMapping": "Asignación de puertos", "portMappingSubtitle": "Reglas de reenvío de puertos y configuración de DMZ", "portMustBe1To65535": "El puerto debe estar entre 1 y 65535", - "portRangeWithCount": "Intervalo de puertos ({count})", "portRules": "Reglas de puertos", - "portTriggering": "Activación de puertos", + "portTriggering": "Activación de intervalo de puertos", "ports": "Puertos", "potentialIssues": "Problemas potenciales", "pppStatus": "Estado PPP", @@ -882,6 +904,12 @@ "renew": "Renovar", "requiredLabel": "(Obligatorio)", "reservationReleased": "Reserva liberada", + "reservationAdded": "Reserva añadida", + "reservationDeleted": "Reserva eliminada", + "reconnectedToRouter": "Reconectado al router", + "timeSettingsSaved": "Configuración de hora guardada", + "ruleAdded": "Regla añadida", + "channelUpdated": "Canal actualizado", "reservations": "Reservas", "reserveIpAddress": "Reservar dirección IP", "reserved": "Reservado", @@ -908,6 +936,8 @@ "routerRebootComplete": "Reinicio del router completado", "routerWritingImage": "El router está escribiendo la nueva imagen. No apague el dispositivo.", "rtColumn": "RT", + "unableToLoadDiagnostics": "No se ha podido cargar el diagnóstico", + "unableToLoadSpeedTest": "No se ha podido cargar la prueba de velocidad", "rules": "Reglas", "runAgain": "Ejecutar de nuevo", "runDiagnosticsTitle": "Ejecutar diagnóstico", @@ -942,7 +972,6 @@ "signalQuality": "Calidad de señal", "signalQualitySubtitle": "Calidad de señal WiFi por banda de frecuencia", "signalStrengthDbm": "{value} dBm", - "singlePortWithCount": "Un solo puerto ({count})", "singleRouterSetupNoBackhaul": "Configuración de un solo router — sin backhaul", "sizeBytes": "Tamaño: {size} bytes ({mib} MiB)", "skipped": "Omitido", @@ -1003,15 +1032,10 @@ "trend": "Tendencia", "trends": "Tendencias", "triggerPorts": "Puertos de activación", - "triggeringWithCount": "Activación ({count})", "txPower": "Potencia de Tx", "type": "Tipo", "typeAMessage": "Escriba un mensaje...", "unableToGatherDeviceInfo": "No se ha podido recopilar la información del dispositivo", - "unableToLoadApps": "No se han podido cargar las aplicaciones", - "unableToLoadDiagnostics": "No se ha podido cargar el diagnóstico", - "unableToLoadSpeedTest": "No se ha podido cargar la prueba de velocidad", - "unableToLoadTopology": "No se ha podido cargar la topología", "unchangedLabel": "(Sin cambios)", "unknownWidget": "Widget desconocido: {id}", "unnamed": "(sin nombre)", @@ -1099,5 +1123,12 @@ "targetChain": "Cadena de destino", "synchronized": "Sincronizado", "noPortForwardingRulesConfigured": "No hay reglas de reenvío de puertos configuradas", - "nItems": "{count, plural, one{{count} elemento} other{{count} elementos}}" + "nItems": "{count, plural, one{{count} elemento} other{{count} elementos}}", + "bridgeReconnectHint": "Después de guardar, vuelve a conectarte a tu router en {url}.", + "bridgeRedirectTitle": "Vuelve a conectarte a tu router", + "bridgeRedirectMessage": "Tu router ahora es un puente transparente y ya no asigna direcciones IP locales. Vuelve a conectarte en {url}.", + "bridgeRedirectButton": "Ir al router", + "lanIpRedirectTitle": "Vuelve a conectarte a tu router", + "lanIpRedirectMessage": "La dirección IP de tu router ha cambiado. Vuelve a conectarte a él en {url}. Es posible que debas esperar un momento a que tu dispositivo obtenga una nueva dirección.", + "lanIpRedirectButton": "Ir al router" } diff --git a/lib/l10n/app_es_ar.arb b/lib/l10n/app_es_ar.arb index 59b7fa350..5d903e484 100644 --- a/lib/l10n/app_es_ar.arb +++ b/lib/l10n/app_es_ar.arb @@ -126,6 +126,8 @@ "invalidDestinationMacAddress": "Dirección MAC de destino no válida", "invalidDns": "DNS no válido", "invalidGatewayIpAddress": "Dirección IP de la puerta de enlace no válida", + "gatewayMustBeWithinLanSubnet": "La puerta de enlace debe estar dentro de la subred LAN", + "gatewayMustBeOutsideLanSubnet": "La puerta de enlace debe estar fuera de la subred LAN", "invalidInput": "Respuesta incorrecta", "invalidIpAddress": "Dirección IP no válida", "invalidMACAddress": "Dirección MAC no válida.", @@ -134,6 +136,7 @@ "ipv4": "IPv4", "ipv6": "IPv6", "ipv6Address": "Dirección IPv6", + "ipv6ScopeLinkLocal": "Enlace local", "keepAlive": "Mantener activo", "l2tpPassthrough": "Paso a través de L2TP", "labelInterface": "Interfaz", @@ -239,9 +242,11 @@ "prefix": "Prefijo", "prefixLength": "Longitud del prefijo", "print": "Imprimir", + "privateMac": "Privada", "privacyAndSecurity": "Privacidad y Seguridad", "processing": "Procesando...", "protocol": "Protocolo", + "publicMac": "Pública", "quickSetup": "Configuración rápida", "applyToAllBandsDesc": "Aplicar la misma configuración de WiFi a todas las bandas a la vez", "rebootChildTitle": "Reiniciar el nodo y todos sus nodos secundarios", @@ -380,7 +385,7 @@ "addDeviceManually": "Agregar dispositivo manualmente", "addPortForwarding": "Agregar reenvío de puertos", "addPortRangeForwarding": "Agregar reenvío de rango de puertos", - "addPortTriggering": "Agregar activación de puertos", + "addPortTriggering": "Añadir activación de rango de puertos", "addedWidgetNamed": "Se agregó {name}", "adding": "Agregando…", "additionalFiltersOnlineOnly": "Los filtros adicionales solo están disponibles para dispositivos en línea.", @@ -434,6 +439,17 @@ "changeNetworkSettingsTitle": "¿Cambiar la configuración de red?", "changeRouterPassword": "Cambiar contraseña del router", "channelNumber": "Número de canal", + "channelAutoRecommended": "Auto (recommended)", + "channelCurrentlyUsing": "Currently using: {channel}", + "@channelCurrentlyUsing": { + "placeholders": { + "channel": { + "type": "int" + } + } + }, + "channelDfsSuffix": "· DFS", + "channelNoManualOptions": "No manual channels available for this band.", "channels": "Canales", "checkConnectivityAndSpeed": "Verificar conectividad y velocidad", "checkForUpdates": "Buscar actualizaciones", @@ -462,6 +478,8 @@ "confirmationRequired": "Se requiere confirmación", "confirmingNewFirmware": "Confirmando que el nuevo firmware está en ejecución…", "connect": "Conectar", + "duplicateMacAddress": "Esta dirección MAC ya está reservada", + "duplicateIpAddress": "Esta dirección IP ya está reservada", "connectedDevices": "Dispositivos conectados", "connecting": "Conectando...", "connectingToRouter": "Conectando al router...", @@ -546,6 +564,9 @@ "diagnosticsRecWeakWifiTitle": "Señal WiFi débil", "disable": "Desactivar", "disableInstantPrivacyDesc": "Todos los dispositivos podrán conectarse libremente a su red.", + "privateMacWarningTitle": "Algunos dispositivos usan una dirección Wi-Fi privada", + "privateMacWarningDesc": "Los dispositivos que se muestran abajo usan una dirección Wi-Fi privada (aleatoria). Como esta dirección cambia con el tiempo, pueden quedar bloqueados cuando rote —incluso el que estás usando ahora—. Para no perder la conexión, desactivá la \"Dirección Wi-Fi privada\" en estos dispositivos antes de activar la Privacidad instantánea.", + "privateMacLabel": "Privada", "disableInstantPrivacyTitle": "¿Desactivar la Privacidad instantánea?", "discards": "Descartes", "distribution": "Distribución", @@ -572,7 +593,7 @@ "dynamicFrequencySelection": "Selección dinámica de frecuencia (DFS)", "editPortForwarding": "Editar reenvío de puertos", "editPortRangeForwarding": "Editar reenvío de rango de puertos", - "editPortTriggering": "Editar activación de puertos", + "editPortTriggering": "Editar activación de rango de puertos", "editRule": "Editar regla", "editStaticRoute": "Editar ruta estática", "editTimeSettings": "Editar configuración de hora", @@ -596,7 +617,6 @@ "errorInvalidCredentials": "Nombre de usuario o contraseña incorrectos.", "errorInvalidInput": "El valor ingresado no es válido. Verifíquelo e inténtelo de nuevo.", "errorInvalidSessionToken": "Su sesión no es válida. Vuelva a iniciar sesión.", - "errorLoadingSpeedTest": "Error al cargar la prueba de velocidad", "errorNetwork": "Error de red. Verifique su conexión e inténtelo de nuevo.", "errorNotAuthenticated": "No ha iniciado sesión. Inicie sesión e inténtelo de nuevo.", "errorResourceNotFound": "No se pudo encontrar la configuración solicitada en el router.", @@ -763,6 +783,9 @@ "noAdvancedWifiSettings": "No hay configuración avanzada de WiFi disponible para este dispositivo.", "noAnswersReturned": "No se devolvieron respuestas.", "noAppsInstalled": "No hay apps instaladas en este router", + "unableToLoadApps": "No se pudieron cargar las apps", + "unableToLoadHealthData": "No se pudieron cargar los datos de estado", + "unableToLoadTopology": "No se pudo cargar la topología", "noClients": "Sin clientes", "noDeviceActivityRecorded": "No se registró actividad de dispositivos", "noDevicesCurrentlyConnected": "No hay dispositivos conectados actualmente.", @@ -780,7 +803,7 @@ "noLogFilesAvailable": "No hay archivos de registro disponibles en este router", "noPortMappingsConfigured": "No hay asignaciones de puertos configuradas", "noPortRangeRules": "No hay reglas de reenvío de rango de puertos configuradas", - "noPortTriggeringRules": "No hay reglas de activación de puertos configuradas", + "noPortTriggeringRules": "No hay reglas de activación de rango de puertos configuradas", "noPresetSelected": "No hay ningún preajuste seleccionado", "noReservationIpMayChange": "Sin reserva. La IP puede cambiar al reconectarse.", "noSinglePortRules": "No hay reglas de reenvío de un solo puerto configuradas", @@ -836,9 +859,8 @@ "portMapping": "Asignación de puertos", "portMappingSubtitle": "Reglas de reenvío de puertos y configuración de DMZ", "portMustBe1To65535": "El puerto debe estar entre 1 y 65535", - "portRangeWithCount": "Rango de puertos ({count})", "portRules": "Reglas de puertos", - "portTriggering": "Activación de puertos", + "portTriggering": "Activación de rango de puertos", "ports": "Puertos", "potentialIssues": "Posibles problemas", "pppStatus": "Estado PPP", @@ -882,6 +904,12 @@ "renew": "Renovar", "requiredLabel": "(Obligatorio)", "reservationReleased": "Reserva liberada", + "reservationAdded": "Reserva agregada", + "reservationDeleted": "Reserva eliminada", + "reconnectedToRouter": "Reconectado al router", + "timeSettingsSaved": "Configuración de hora guardada", + "ruleAdded": "Regla agregada", + "channelUpdated": "Canal actualizado", "reservations": "Reservas", "reserveIpAddress": "Reservar dirección IP", "reserved": "Reservado", @@ -908,6 +936,8 @@ "routerRebootComplete": "Reinicio del router completado", "routerWritingImage": "El router está escribiendo la nueva imagen. No apague el equipo.", "rtColumn": "RT", + "unableToLoadDiagnostics": "No se pudo cargar el diagnóstico", + "unableToLoadSpeedTest": "No se pudo cargar la prueba de velocidad", "rules": "Reglas", "runAgain": "Ejecutar de nuevo", "runDiagnosticsTitle": "Ejecutar diagnóstico", @@ -942,7 +972,6 @@ "signalQuality": "Calidad de señal", "signalQualitySubtitle": "Calidad de señal WiFi por banda de frecuencia", "signalStrengthDbm": "{value} dBm", - "singlePortWithCount": "Un solo puerto ({count})", "singleRouterSetupNoBackhaul": "Configuración de un solo router: sin backhaul", "sizeBytes": "Tamaño: {size} bytes ({mib} MiB)", "skipped": "Omitido", @@ -1003,15 +1032,10 @@ "trend": "Tendencia", "trends": "Tendencias", "triggerPorts": "Puertos de activación", - "triggeringWithCount": "Activación ({count})", "txPower": "Potencia de transmisión", "type": "Tipo", "typeAMessage": "Escriba un mensaje...", "unableToGatherDeviceInfo": "No se pudo recopilar la información del dispositivo", - "unableToLoadApps": "No se pudieron cargar las apps", - "unableToLoadDiagnostics": "No se pudo cargar el diagnóstico", - "unableToLoadSpeedTest": "No se pudo cargar la prueba de velocidad", - "unableToLoadTopology": "No se pudo cargar la topología", "unchangedLabel": "(Sin cambios)", "unknownWidget": "Widget desconocido: {id}", "unnamed": "(sin nombre)", @@ -1099,5 +1123,12 @@ "targetChain": "Cadena de destino", "synchronized": "Sincronizado", "noPortForwardingRulesConfigured": "No hay reglas de reenvío de puertos configuradas", - "nItems": "{count, plural, one{{count} elemento} other{{count} elementos}}" + "nItems": "{count, plural, one{{count} elemento} other{{count} elementos}}", + "bridgeReconnectHint": "Después de guardar, volvé a conectarte a tu router en {url}.", + "bridgeRedirectTitle": "Volvé a conectarte a tu router", + "bridgeRedirectMessage": "Tu router ahora es un puente transparente y ya no asigna direcciones IP locales. Volvé a conectarte en {url}.", + "bridgeRedirectButton": "Ir al router", + "lanIpRedirectTitle": "Volvé a conectarte a tu router", + "lanIpRedirectMessage": "La dirección IP de tu router cambió. Volvé a conectarte a él en {url}. Puede que tengas que esperar un momento a que tu dispositivo obtenga una nueva dirección.", + "lanIpRedirectButton": "Ir al router" } diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb index 229e30001..c3e100f87 100644 --- a/lib/l10n/app_fi.arb +++ b/lib/l10n/app_fi.arb @@ -126,6 +126,8 @@ "invalidDestinationMacAddress": "Virheellinen kohteen MAC-osoite", "invalidDns": "Virheellinen DNS", "invalidGatewayIpAddress": "Virheellinen yhdyskäytävän IP-osoite", + "gatewayMustBeWithinLanSubnet": "Yhdyskäytävän on oltava LAN-aliverkon sisällä", + "gatewayMustBeOutsideLanSubnet": "Yhdyskäytävän on oltava LAN-aliverkon ulkopuolella", "invalidInput": "Virheellinen syöte", "invalidIpAddress": "Virheellinen IP-osoite", "invalidMACAddress": "Virheellinen MAC-osoite.", @@ -134,6 +136,7 @@ "ipv4": "IPv4", "ipv6": "IPv6", "ipv6Address": "IPv6-osoite", + "ipv6ScopeLinkLocal": "Linkkikohtainen", "keepAlive": "Aina käytössä", "l2tpPassthrough": "L2TP-läpiohjaus", "labelInterface": "Käyttöliittymä", @@ -237,9 +240,11 @@ "prefix": "Etuliite", "prefixLength": "Etuliitteen pituus", "print": "Tulosta", + "privateMac": "Yksityinen", "privacyAndSecurity": "Tietosuoja ja turvallisuus", "processing": "Käsitellään...", "protocol": "Protokolla", + "publicMac": "Julkinen", "quickSetup": "Pika-asetus", "applyToAllBandsDesc": "Käytä samoja WiFi-asetuksia kaikille taajuusalueille kerralla", "rebootChildTitle": "Käynnistä solmu ja kaikki sen alisolmut uudelleen", @@ -378,7 +383,7 @@ "addDeviceManually": "Lisää laite manuaalisesti", "addPortForwarding": "Lisää portinohjaus", "addPortRangeForwarding": "Lisää porttialueen ohjaus", - "addPortTriggering": "Lisää porttien laukaisu", + "addPortTriggering": "Lisää porttialueen laukaisu", "addedWidgetNamed": "Lisätty {name}", "adding": "Lisätään…", "additionalFiltersOnlineOnly": "Lisäsuodattimet ovat käytettävissä vain online-laitteille.", @@ -432,6 +437,17 @@ "changeNetworkSettingsTitle": "Muutetaanko verkkoasetuksia?", "changeRouterPassword": "Vaihda reitittimen salasana", "channelNumber": "Kanavanumero", + "channelAutoRecommended": "Auto (recommended)", + "channelCurrentlyUsing": "Currently using: {channel}", + "@channelCurrentlyUsing": { + "placeholders": { + "channel": { + "type": "int" + } + } + }, + "channelDfsSuffix": "· DFS", + "channelNoManualOptions": "No manual channels available for this band.", "channels": "Kanavat", "checkConnectivityAndSpeed": "Tarkista yhteys ja nopeus", "checkForUpdates": "Tarkista päivitykset", @@ -460,6 +476,8 @@ "confirmationRequired": "Vahvistus vaaditaan", "confirmingNewFirmware": "Vahvistetaan, että uusi laiteohjelmisto on käynnissä…", "connect": "Yhdistä", + "duplicateMacAddress": "Tämä MAC-osoite on jo varattu", + "duplicateIpAddress": "Tämä IP-osoite on jo varattu", "connectedDevices": "Yhdistetyt laitteet", "connecting": "Yhdistetään...", "connectingToRouter": "Yhdistetään reitittimeen...", @@ -544,6 +562,9 @@ "diagnosticsRecWeakWifiTitle": "Heikko WiFi-signaali", "disable": "Poista käytöstä", "disableInstantPrivacyDesc": "Kaikki laitteet voivat yhdistää vapaasti verkkoosi.", + "privateMacWarningTitle": "Jotkin laitteet käyttävät yksityistä Wi-Fi-osoitetta", + "privateMacWarningDesc": "Alla luetellut laitteet käyttävät yksityistä (satunnaistettua) Wi-Fi-osoitetta. Koska tämä osoite muuttuu ajan myötä, ne voidaan estää osoitteen vaihduttua — myös laite, jota käytät juuri nyt. Pysyäksesi yhteydessä poista näiden laitteiden \"Yksityinen Wi-Fi-osoite\" käytöstä ennen kuin otat Instant Privacyn käyttöön.", + "privateMacLabel": "Yksityinen", "disableInstantPrivacyTitle": "Poistetaanko Instant Privacy käytöstä?", "discards": "Hylätyt", "distribution": "Jakauma", @@ -570,7 +591,7 @@ "dynamicFrequencySelection": "Dynaaminen taajuusvalinta (DFS)", "editPortForwarding": "Muokkaa portinohjausta", "editPortRangeForwarding": "Muokkaa porttialueen ohjausta", - "editPortTriggering": "Muokkaa porttien laukaisua", + "editPortTriggering": "Muokkaa porttialueen laukaisua", "editRule": "Muokkaa sääntöä", "editStaticRoute": "Muokkaa staattista reittiä", "editTimeSettings": "Muokkaa aika-asetuksia", @@ -594,7 +615,6 @@ "errorInvalidCredentials": "Väärä käyttäjänimi tai salasana.", "errorInvalidInput": "Annettu arvo ei kelpaa. Tarkista ja yritä uudelleen.", "errorInvalidSessionToken": "Istuntosi on virheellinen. Kirjaudu sisään uudelleen.", - "errorLoadingSpeedTest": "Virhe ladattaessa nopeustestiä", "errorNetwork": "Verkkovirhe. Tarkista yhteytesi ja yritä uudelleen.", "errorNotAuthenticated": "Et ole kirjautunut sisään. Kirjaudu sisään ja yritä uudelleen.", "errorResourceNotFound": "Pyydettyä asetusta ei löytynyt reitittimestä.", @@ -763,6 +783,9 @@ "noAdvancedWifiSettings": "Tälle laitteelle ei ole saatavilla WiFi-lisäasetuksia.", "noAnswersReturned": "Vastauksia ei palautettu.", "noAppsInstalled": "Tähän reitittimeen ei ole asennettu sovelluksia", + "unableToLoadApps": "Sovelluksia ei voitu ladata", + "unableToLoadHealthData": "Tilatietoja ei voitu ladata", + "unableToLoadTopology": "Topologiaa ei voitu ladata", "noClients": "Ei asiakaslaitteita", "noDeviceActivityRecorded": "Laitetoimintaa ei ole kirjattu", "noDevicesCurrentlyConnected": "Yhtään laitetta ei ole tällä hetkellä yhdistettynä.", @@ -780,7 +803,7 @@ "noLogFilesAvailable": "Tässä reitittimessä ei ole lokitiedostoja saatavilla", "noPortMappingsConfigured": "Yhtään porttimäppäystä ei ole määritetty", "noPortRangeRules": "Yhtään porttialueen ohjaussääntöä ei ole määritetty", - "noPortTriggeringRules": "Yhtään porttien laukaisusääntöä ei ole määritetty", + "noPortTriggeringRules": "Ei porttialueen laukaisusääntöjä määritetty", "noPresetSelected": "Esiasetusta ei ole valittu", "noReservationIpMayChange": "Ei varausta. IP voi muuttua uudelleenyhdistettäessä.", "noSinglePortRules": "Yhtään yksittäisen portin ohjaussääntöä ei ole määritetty", @@ -836,9 +859,8 @@ "portMapping": "Porttimäppäys", "portMappingSubtitle": "Portinohjaussäännöt ja DMZ-määritykset", "portMustBe1To65535": "Portin on oltava 1–65535", - "portRangeWithCount": "Porttialue ({count})", "portRules": "Porttisäännöt", - "portTriggering": "Porttien laukaisu", + "portTriggering": "Porttialueen laukaisu", "ports": "Portit", "potentialIssues": "Mahdolliset ongelmat", "pppStatus": "PPP-tila", @@ -882,6 +904,12 @@ "renew": "Uusi", "requiredLabel": "(Pakollinen)", "reservationReleased": "Varaus vapautettu", + "reservationAdded": "Varaus lisätty", + "reservationDeleted": "Varaus poistettu", + "reconnectedToRouter": "Yhteys reitittimeen muodostettu uudelleen", + "timeSettingsSaved": "Aika-asetukset tallennettu", + "ruleAdded": "Sääntö lisätty", + "channelUpdated": "Kanava päivitetty", "reservations": "Varaukset", "reserveIpAddress": "Varaa IP-osoite", "reserved": "Varattu", @@ -908,6 +936,8 @@ "routerRebootComplete": "Reitittimen uudelleenkäynnistys valmis", "routerWritingImage": "Reititin kirjoittaa uutta vedosta. Älä katkaise virtaa.", "rtColumn": "RT", + "unableToLoadDiagnostics": "Diagnostiikkaa ei voitu ladata", + "unableToLoadSpeedTest": "Nopeustestiä ei voitu ladata", "rules": "Säännöt", "runAgain": "Suorita uudelleen", "runDiagnosticsTitle": "Suorita diagnostiikka", @@ -942,7 +972,6 @@ "signalQuality": "Signaalin laatu", "signalQualitySubtitle": "WiFi-signaalin laatu taajuusalueittain", "signalStrengthDbm": "{value} dBm", - "singlePortWithCount": "Yksittäinen portti ({count})", "singleRouterSetupNoBackhaul": "Yhden reitittimen asennus — ei runkoverkkoa", "sizeBytes": "Koko: {size} tavua ({mib} MiB)", "skipped": "Ohitettu", @@ -1003,15 +1032,10 @@ "trend": "Trendi", "trends": "Trendit", "triggerPorts": "Laukaisuportit", - "triggeringWithCount": "Laukaisu ({count})", "txPower": "Lähetysteho", "type": "Tyyppi", "typeAMessage": "Kirjoita viesti...", "unableToGatherDeviceInfo": "Laitetietoja ei voitu kerätä", - "unableToLoadApps": "Sovelluksia ei voitu ladata", - "unableToLoadDiagnostics": "Diagnostiikkaa ei voitu ladata", - "unableToLoadSpeedTest": "Nopeustestiä ei voitu ladata", - "unableToLoadTopology": "Topologiaa ei voitu ladata", "unchangedLabel": "(Muuttumaton)", "unknownWidget": "Tuntematon widget: {id}", "unnamed": "(nimetön)", @@ -1099,5 +1123,12 @@ "targetChain": "Kohdeketju", "synchronized": "Synkronoitu", "noPortForwardingRulesConfigured": "Porttiohjaussääntöjä ei ole määritetty", - "nItems": "{count, plural, one{{count} kohde} other{{count} kohdetta}}" + "nItems": "{count, plural, one{{count} kohde} other{{count} kohdetta}}", + "bridgeReconnectHint": "Yhdistä tallennuksen jälkeen reitittimeesi uudelleen osoitteessa {url}.", + "bridgeRedirectTitle": "Yhdistä reitittimeesi uudelleen", + "bridgeRedirectMessage": "Reitittimesi toimii nyt läpinäkyvänä siltana eikä enää jaa paikallisia IP-osoitteita. Yhdistä uudelleen osoitteessa {url}.", + "bridgeRedirectButton": "Siirry reitittimeen", + "lanIpRedirectTitle": "Yhdistä reitittimeesi uudelleen", + "lanIpRedirectMessage": "Reitittimesi IP-osoite on muuttunut. Yhdistä siihen uudelleen osoitteessa {url}. Sinun on ehkä odotettava hetki, että laitteesi saa uuden osoitteen.", + "lanIpRedirectButton": "Siirry reitittimeen" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index c411ce3a8..df807fd85 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -126,6 +126,8 @@ "invalidDestinationMacAddress": "Adresse MAC de destination non valide", "invalidDns": "DNS non valide", "invalidGatewayIpAddress": "Adresse IP de la passerelle invalide", + "gatewayMustBeWithinLanSubnet": "La passerelle doit être dans le sous-réseau LAN", + "gatewayMustBeOutsideLanSubnet": "La passerelle doit être en dehors du sous-réseau LAN", "invalidInput": "Entrée non valide", "invalidIpAddress": "Adresse IP non valide", "invalidMACAddress": "Adresse MAC incorrecte.", @@ -134,6 +136,7 @@ "ipv4": "IPv4", "ipv6": "IPv6", "ipv6Address": "Adresse IPv6", + "ipv6ScopeLinkLocal": "Lien-local", "keepAlive": "Maintenir la connexion", "l2tpPassthrough": "Passthrough L2TP", "labelInterface": "Interface", @@ -239,9 +242,11 @@ "prefix": "Préfixe", "prefixLength": "Longueur du préfixe", "print": "Imprimer", + "privateMac": "Privée", "privacyAndSecurity": "Confidentialité et Sécurité", "processing": "En cours de traitement...", "protocol": "Protocole", + "publicMac": "Publique", "quickSetup": "Configuration rapide", "applyToAllBandsDesc": "Appliquer les mêmes paramètres WiFi à toutes les bandes en une seule fois", "rebootChildTitle": "Redémarrer le nœud et tous ses nœuds enfants", @@ -380,7 +385,7 @@ "addDeviceManually": "Ajouter un périphérique manuellement", "addPortForwarding": "Ajouter une redirection de port", "addPortRangeForwarding": "Ajouter une redirection de plage de ports", - "addPortTriggering": "Ajouter un déclenchement de port", + "addPortTriggering": "Ajouter un déclenchement de plage de ports", "addedWidgetNamed": "{name} ajouté", "adding": "Ajout en cours…", "additionalFiltersOnlineOnly": "Les filtres supplémentaires ne sont disponibles que pour les périphériques en ligne.", @@ -434,6 +439,17 @@ "changeNetworkSettingsTitle": "Modifier les paramètres réseau ?", "changeRouterPassword": "Modifier le mot de passe du routeur", "channelNumber": "Numéro de canal", + "channelAutoRecommended": "Auto (recommended)", + "channelCurrentlyUsing": "Currently using: {channel}", + "@channelCurrentlyUsing": { + "placeholders": { + "channel": { + "type": "int" + } + } + }, + "channelDfsSuffix": "· DFS", + "channelNoManualOptions": "No manual channels available for this band.", "channels": "Canaux", "checkConnectivityAndSpeed": "Vérifier la connectivité et la vitesse", "checkForUpdates": "Rechercher des mises à jour", @@ -462,6 +478,8 @@ "confirmationRequired": "Confirmation requise", "confirmingNewFirmware": "Confirmation que le nouveau micrologiciel est en cours d'exécution…", "connect": "Connecter", + "duplicateMacAddress": "Cette adresse MAC est déjà réservée", + "duplicateIpAddress": "Cette adresse IP est déjà réservée", "connectedDevices": "Périphériques connectés", "connecting": "Connexion...", "connectingToRouter": "Connexion au routeur...", @@ -546,6 +564,9 @@ "diagnosticsRecWeakWifiTitle": "Signal WiFi faible", "disable": "Désactiver", "disableInstantPrivacyDesc": "Tous les périphériques pourront se connecter librement à votre réseau.", + "privateMacWarningTitle": "Certains appareils utilisent une adresse Wi-Fi privée", + "privateMacWarningDesc": "Les appareils ci-dessous utilisent une adresse Wi-Fi privée (aléatoire). Comme cette adresse change au fil du temps, ils risquent d’être bloqués après sa rotation — y compris celui que vous utilisez actuellement. Pour rester connecté, désactivez l’option « Adresse Wi-Fi privée » sur ces appareils avant d’activer Instant Privacy.", + "privateMacLabel": "Privé", "disableInstantPrivacyTitle": "Désactiver Instant Privacy ?", "discards": "Rejets", "distribution": "Répartition", @@ -572,7 +593,7 @@ "dynamicFrequencySelection": "Sélection dynamique de fréquence (DFS)", "editPortForwarding": "Modifier la redirection de port", "editPortRangeForwarding": "Modifier la redirection de plage de ports", - "editPortTriggering": "Modifier le déclenchement de port", + "editPortTriggering": "Modifier le déclenchement de plage de ports", "editRule": "Modifier la règle", "editStaticRoute": "Modifier l'itinéraire statique", "editTimeSettings": "Modifier les paramètres d'heure", @@ -596,7 +617,6 @@ "errorInvalidCredentials": "Nom d'utilisateur ou mot de passe incorrect.", "errorInvalidInput": "La valeur saisie n'est pas valide. Veuillez vérifier et réessayer.", "errorInvalidSessionToken": "Votre session n'est pas valide. Veuillez vous reconnecter.", - "errorLoadingSpeedTest": "Erreur lors du chargement du test de vitesse", "errorNetwork": "Erreur réseau. Veuillez vérifier votre connexion et réessayer.", "errorNotAuthenticated": "Vous n'êtes pas connecté. Veuillez vous connecter et réessayer.", "errorResourceNotFound": "Le paramètre demandé est introuvable sur le routeur.", @@ -763,6 +783,9 @@ "noAdvancedWifiSettings": "Aucun paramètre WiFi avancé disponible pour ce périphérique.", "noAnswersReturned": "Aucune réponse renvoyée.", "noAppsInstalled": "Aucune application installée sur ce routeur", + "unableToLoadApps": "Impossible de charger les applications", + "unableToLoadHealthData": "Impossible de charger les données d'intégrité", + "unableToLoadTopology": "Impossible de charger la topologie", "noClients": "Aucun client", "noDeviceActivityRecorded": "Aucune activité de périphérique enregistrée", "noDevicesCurrentlyConnected": "Aucun périphérique n'est actuellement connecté.", @@ -780,7 +803,7 @@ "noLogFilesAvailable": "Aucun fichier journal disponible sur ce routeur", "noPortMappingsConfigured": "Aucun mappage de port configuré", "noPortRangeRules": "Aucune règle de redirection de plage de ports configurée", - "noPortTriggeringRules": "Aucune règle de déclenchement de port configurée", + "noPortTriggeringRules": "Aucune règle de déclenchement de plage de ports configurée", "noPresetSelected": "Aucun préréglage sélectionné", "noReservationIpMayChange": "Aucune réservation. L'IP peut changer à la reconnexion.", "noSinglePortRules": "Aucune règle de redirection de port unique configurée", @@ -836,9 +859,8 @@ "portMapping": "Mappage de port", "portMappingSubtitle": "Règles de redirection de port et configuration DMZ", "portMustBe1To65535": "Le port doit être compris entre 1 et 65535", - "portRangeWithCount": "Plage de ports ({count})", "portRules": "Règles de port", - "portTriggering": "Déclenchement de port", + "portTriggering": "Déclenchement de plage de ports", "ports": "Ports", "potentialIssues": "Problèmes potentiels", "pppStatus": "État PPP", @@ -882,6 +904,12 @@ "renew": "Renouveler", "requiredLabel": "(Obligatoire)", "reservationReleased": "Réservation libérée", + "reservationAdded": "Réservation ajoutée", + "reservationDeleted": "Réservation supprimée", + "reconnectedToRouter": "Reconnecté au routeur", + "timeSettingsSaved": "Paramètres d'heure enregistrés", + "ruleAdded": "Règle ajoutée", + "channelUpdated": "Canal mis à jour", "reservations": "Réservations", "reserveIpAddress": "Réserver une adresse IP", "reserved": "Réservé", @@ -908,6 +936,8 @@ "routerRebootComplete": "Redémarrage du routeur terminé", "routerWritingImage": "Le routeur écrit la nouvelle image. N'éteignez pas l'appareil.", "rtColumn": "RT", + "unableToLoadDiagnostics": "Impossible de charger le diagnostic", + "unableToLoadSpeedTest": "Impossible de charger le test de vitesse", "rules": "Règles", "runAgain": "Exécuter à nouveau", "runDiagnosticsTitle": "Exécuter les diagnostics", @@ -942,7 +972,6 @@ "signalQuality": "Qualité du signal", "signalQualitySubtitle": "Qualité du signal WiFi par bande de fréquence", "signalStrengthDbm": "{value} dBm", - "singlePortWithCount": "Port unique ({count})", "singleRouterSetupNoBackhaul": "Configuration à routeur unique — pas de backhaul", "sizeBytes": "Taille : {size} octets ({mib} Mio)", "skipped": "Ignoré", @@ -1003,15 +1032,10 @@ "trend": "Tendance", "trends": "Tendances", "triggerPorts": "Ports de déclenchement", - "triggeringWithCount": "Déclenchement ({count})", "txPower": "Puissance Tx", "type": "Type", "typeAMessage": "Saisissez un message...", "unableToGatherDeviceInfo": "Impossible de recueillir les informations du périphérique", - "unableToLoadApps": "Impossible de charger les applications", - "unableToLoadDiagnostics": "Impossible de charger les diagnostics", - "unableToLoadSpeedTest": "Impossible de charger le test de vitesse", - "unableToLoadTopology": "Impossible de charger la topologie", "unchangedLabel": "(Inchangé)", "unknownWidget": "Widget inconnu : {id}", "unnamed": "(sans nom)", @@ -1099,5 +1123,12 @@ "targetChain": "Chaîne cible", "synchronized": "Synchronisé", "noPortForwardingRulesConfigured": "Aucune règle de redirection de port configurée", - "nItems": "{count, plural, one{{count} élément} many{{count} d'éléments} other{{count} éléments}}" + "nItems": "{count, plural, one{{count} élément} many{{count} d'éléments} other{{count} éléments}}", + "bridgeReconnectHint": "Après l'enregistrement, reconnectez-vous à votre routeur à l'adresse {url}.", + "bridgeRedirectTitle": "Se reconnecter à votre routeur", + "bridgeRedirectMessage": "Votre routeur fonctionne désormais comme un pont transparent et n'attribue plus d'adresses IP locales. Reconnectez-vous à l'adresse {url}.", + "bridgeRedirectButton": "Accéder au routeur", + "lanIpRedirectTitle": "Se reconnecter à votre routeur", + "lanIpRedirectMessage": "L'adresse IP de votre routeur a changé. Reconnectez-vous à celui-ci à l'adresse {url}. Vous devrez peut-être patienter un instant le temps que votre appareil obtienne une nouvelle adresse.", + "lanIpRedirectButton": "Accéder au routeur" } diff --git a/lib/l10n/app_fr_ca.arb b/lib/l10n/app_fr_ca.arb index 03cf082a4..27f0e74ab 100644 --- a/lib/l10n/app_fr_ca.arb +++ b/lib/l10n/app_fr_ca.arb @@ -126,6 +126,8 @@ "invalidDestinationMacAddress": "Adresse MAC de destination non valide", "invalidDns": "DNS non valide", "invalidGatewayIpAddress": "Adresse IP de la passerelle invalide", + "gatewayMustBeWithinLanSubnet": "La passerelle doit être dans le sous-réseau LAN", + "gatewayMustBeOutsideLanSubnet": "La passerelle doit être en dehors du sous-réseau LAN", "invalidInput": "Saisie incorrecte", "invalidIpAddress": "Adresse IP non valide", "invalidMACAddress": "Adresse MAC non valide.", @@ -134,6 +136,7 @@ "ipv4": "IPv4", "ipv6": "IPv6", "ipv6Address": "Adresse IPv6", + "ipv6ScopeLinkLocal": "Lien-local", "keepAlive": "Maintenir la connexion", "l2tpPassthrough": "Intercommunication L2TP", "labelInterface": "Interface", @@ -239,9 +242,11 @@ "prefix": "Préfixe", "prefixLength": "Longueur du préfixe", "print": "Imprimer", + "privateMac": "Privée", "privacyAndSecurity": "Confidentialité et Sécurité", "processing": "En cours de traitement...", "protocol": "Protocole", + "publicMac": "Publique", "quickSetup": "Configuration rapide", "applyToAllBandsDesc": "Appliquer les mêmes paramètres WiFi à toutes les bandes en une seule fois", "rebootChildTitle": "Redémarrer le nœud et tous ses nœuds enfants", @@ -380,7 +385,7 @@ "addDeviceManually": "Ajouter un appareil manuellement", "addPortForwarding": "Ajouter une redirection de port", "addPortRangeForwarding": "Ajouter une redirection de plage de ports", - "addPortTriggering": "Ajouter un déclenchement de port", + "addPortTriggering": "Ajouter un déclenchement de plage de ports", "addedWidgetNamed": "{name} ajouté", "adding": "Ajout en cours…", "additionalFiltersOnlineOnly": "Les filtres supplémentaires ne sont offerts que pour les appareils en ligne.", @@ -434,6 +439,17 @@ "changeNetworkSettingsTitle": "Modifier les paramètres réseau?", "changeRouterPassword": "Modifier le mot de passe du routeur", "channelNumber": "Numéro de canal", + "channelAutoRecommended": "Auto (recommended)", + "channelCurrentlyUsing": "Currently using: {channel}", + "@channelCurrentlyUsing": { + "placeholders": { + "channel": { + "type": "int" + } + } + }, + "channelDfsSuffix": "· DFS", + "channelNoManualOptions": "No manual channels available for this band.", "channels": "Canaux", "checkConnectivityAndSpeed": "Vérifier la connectivité et la vitesse", "checkForUpdates": "Rechercher des mises à jour", @@ -462,6 +478,8 @@ "confirmationRequired": "Confirmation requise", "confirmingNewFirmware": "Confirmation de l'exécution du nouveau micrologiciel…", "connect": "Connecter", + "duplicateMacAddress": "Cette adresse MAC est déjà réservée", + "duplicateIpAddress": "Cette adresse IP est déjà réservée", "connectedDevices": "Appareils connectés", "connecting": "Connexion...", "connectingToRouter": "Connexion au routeur...", @@ -546,6 +564,9 @@ "diagnosticsRecWeakWifiTitle": "Signal WiFi faible", "disable": "Désactiver", "disableInstantPrivacyDesc": "Tous les appareils pourront se connecter librement à votre réseau.", + "privateMacWarningTitle": "Certains appareils utilisent une adresse Wi-Fi privée", + "privateMacWarningDesc": "Les appareils ci-dessous utilisent une adresse Wi-Fi privée (aléatoire). Comme cette adresse change au fil du temps, ils risquent d’être bloqués après sa rotation — y compris celui que vous utilisez en ce moment. Pour rester connecté, désactivez l’option « Adresse Wi-Fi privée » sur ces appareils avant d’activer Instant-Confidentialité.", + "privateMacLabel": "Privé", "disableInstantPrivacyTitle": "Désactiver Instant-Confidentialité?", "discards": "Rejets", "distribution": "Répartition", @@ -572,7 +593,7 @@ "dynamicFrequencySelection": "Sélection dynamique de fréquence (DFS)", "editPortForwarding": "Modifier la redirection de port", "editPortRangeForwarding": "Modifier la redirection de plage de ports", - "editPortTriggering": "Modifier le déclenchement de port", + "editPortTriggering": "Modifier le déclenchement de plage de ports", "editRule": "Modifier la règle", "editStaticRoute": "Modifier la route statique", "editTimeSettings": "Modifier les paramètres d'heure", @@ -596,7 +617,6 @@ "errorInvalidCredentials": "Nom d'utilisateur ou mot de passe incorrect.", "errorInvalidInput": "La valeur saisie n'est pas valide. Veuillez vérifier et réessayer.", "errorInvalidSessionToken": "Votre session n'est pas valide. Veuillez vous reconnecter.", - "errorLoadingSpeedTest": "Erreur de chargement du test de vitesse", "errorNetwork": "Erreur réseau. Veuillez vérifier votre connexion et réessayer.", "errorNotAuthenticated": "Vous n'êtes pas connecté. Veuillez vous connecter et réessayer.", "errorResourceNotFound": "Le paramètre demandé est introuvable sur le routeur.", @@ -763,6 +783,9 @@ "noAdvancedWifiSettings": "Aucun paramètre WiFi avancé disponible pour cet appareil.", "noAnswersReturned": "Aucune réponse retournée.", "noAppsInstalled": "Aucune application installée sur ce routeur", + "unableToLoadApps": "Impossible de charger les applications", + "unableToLoadHealthData": "Impossible de charger les données d'intégrité", + "unableToLoadTopology": "Impossible de charger la topologie", "noClients": "Aucun client", "noDeviceActivityRecorded": "Aucune activité d'appareil enregistrée", "noDevicesCurrentlyConnected": "Aucun appareil n'est actuellement connecté.", @@ -780,7 +803,7 @@ "noLogFilesAvailable": "Aucun fichier journal disponible sur ce routeur", "noPortMappingsConfigured": "Aucun mappage de port configuré", "noPortRangeRules": "Aucune règle de redirection de plage de ports configurée", - "noPortTriggeringRules": "Aucune règle de déclenchement de port configurée", + "noPortTriggeringRules": "Aucune règle de déclenchement de plage de ports configurée", "noPresetSelected": "Aucun préréglage sélectionné", "noReservationIpMayChange": "Aucune réservation. L'IP peut changer à la reconnexion.", "noSinglePortRules": "Aucune règle de redirection de port unique configurée", @@ -836,9 +859,8 @@ "portMapping": "Mappage de port", "portMappingSubtitle": "Règles de redirection de port et configuration DMZ", "portMustBe1To65535": "Le port doit être de 1 à 65535", - "portRangeWithCount": "Plage de ports ({count})", "portRules": "Règles de port", - "portTriggering": "Déclenchement de port", + "portTriggering": "Déclenchement de plage de ports", "ports": "Ports", "potentialIssues": "Problèmes potentiels", "pppStatus": "État PPP", @@ -882,6 +904,12 @@ "renew": "Renouveler", "requiredLabel": "(Requis)", "reservationReleased": "Réservation libérée", + "reservationAdded": "Réservation ajoutée", + "reservationDeleted": "Réservation supprimée", + "reconnectedToRouter": "Reconnecté au routeur", + "timeSettingsSaved": "Paramètres d'heure enregistrés", + "ruleAdded": "Règle ajoutée", + "channelUpdated": "Canal mis à jour", "reservations": "Réservations", "reserveIpAddress": "Réserver l'adresse IP", "reserved": "Réservé", @@ -908,6 +936,8 @@ "routerRebootComplete": "Redémarrage du routeur terminé", "routerWritingImage": "Le routeur écrit la nouvelle image. Ne coupez pas l'alimentation.", "rtColumn": "RT", + "unableToLoadDiagnostics": "Impossible de charger le diagnostic", + "unableToLoadSpeedTest": "Impossible de charger le test de vitesse", "rules": "Règles", "runAgain": "Exécuter de nouveau", "runDiagnosticsTitle": "Exécuter le diagnostic", @@ -942,7 +972,6 @@ "signalQuality": "Qualité du signal", "signalQualitySubtitle": "Qualité du signal WiFi par bande de fréquence", "signalStrengthDbm": "{value} dBm", - "singlePortWithCount": "Port unique ({count})", "singleRouterSetupNoBackhaul": "Configuration à routeur unique — aucune liaison", "sizeBytes": "Taille : {size} octets ({mib} Mio)", "skipped": "Ignoré", @@ -1003,15 +1032,10 @@ "trend": "Tendance", "trends": "Tendances", "triggerPorts": "Ports de déclenchement", - "triggeringWithCount": "Déclenchement ({count})", "txPower": "Puissance Tx", "type": "Type", "typeAMessage": "Tapez un message...", "unableToGatherDeviceInfo": "Impossible de recueillir les renseignements sur l'appareil", - "unableToLoadApps": "Impossible de charger les applications", - "unableToLoadDiagnostics": "Impossible de charger le diagnostic", - "unableToLoadSpeedTest": "Impossible de charger le test de vitesse", - "unableToLoadTopology": "Impossible de charger la topologie", "unchangedLabel": "(Inchangé)", "unknownWidget": "Widget inconnu : {id}", "unnamed": "(sans nom)", @@ -1099,5 +1123,12 @@ "targetChain": "Chaîne cible", "synchronized": "Synchronisé", "noPortForwardingRulesConfigured": "Aucune règle de redirection de port configurée", - "nItems": "{count, plural, one{{count} élément} many{{count} d'éléments} other{{count} éléments}}" + "nItems": "{count, plural, one{{count} élément} many{{count} d'éléments} other{{count} éléments}}", + "bridgeReconnectHint": "Après l'enregistrement, reconnectez-vous à votre routeur à l'adresse {url}.", + "bridgeRedirectTitle": "Se reconnecter à votre routeur", + "bridgeRedirectMessage": "Votre routeur fonctionne maintenant comme un pont transparent et n'attribue plus d'adresses IP locales. Reconnectez-vous à l'adresse {url}.", + "bridgeRedirectButton": "Accéder au routeur", + "lanIpRedirectTitle": "Se reconnecter à votre routeur", + "lanIpRedirectMessage": "L'adresse IP de votre routeur a changé. Reconnectez-vous à celui-ci à l'adresse {url}. Vous devrez peut-être patienter un moment le temps que votre appareil obtienne une nouvelle adresse.", + "lanIpRedirectButton": "Accéder au routeur" } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 1b4682f4c..0ed0d9b70 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -126,6 +126,8 @@ "invalidDestinationMacAddress": "Alamat MAC tujuan tidak valid", "invalidDns": "DNS Tidak Valid", "invalidGatewayIpAddress": "Alamat IP gateway tidak valid", + "gatewayMustBeWithinLanSubnet": "Gateway harus berada dalam subnet LAN", + "gatewayMustBeOutsideLanSubnet": "Gateway harus berada di luar subnet LAN", "invalidInput": "Input tidak valid", "invalidIpAddress": "Alamat IP tidak valid", "invalidMACAddress": "Alamat MAC tidak valid.", @@ -134,6 +136,7 @@ "ipv4": "IPV4", "ipv6": "IPv6", "ipv6Address": "Alamat IPv6", + "ipv6ScopeLinkLocal": "Link-local", "keepAlive": "Aktifkan terus", "l2tpPassthrough": "L2TP Passthrough", "labelInterface": "Antarmuka", @@ -237,9 +240,11 @@ "prefix": "Awalan", "prefixLength": "Panjang awalan", "print": "Cetak", + "privateMac": "Pribadi", "privacyAndSecurity": "Privasi & Keamanan", "processing": "Memproses...", "protocol": "Protokol", + "publicMac": "Publik", "quickSetup": "Pengaturan Cepat", "applyToAllBandsDesc": "Terapkan pengaturan WiFi yang sama ke semua pita sekaligus", "rebootChildTitle": "Nyalakan ulang node dan semua child/nya", @@ -378,7 +383,7 @@ "addDeviceManually": "Tambah perangkat secara manual", "addPortForwarding": "Tambah Penerusan Port", "addPortRangeForwarding": "Tambah Penerusan Rentang Port", - "addPortTriggering": "Tambah Pemicu Port", + "addPortTriggering": "Tambah Pemicu Rentang Port", "addedWidgetNamed": "{name} ditambahkan", "adding": "Menambahkan…", "additionalFiltersOnlineOnly": "Filter tambahan hanya tersedia untuk perangkat yang online.", @@ -432,6 +437,17 @@ "changeNetworkSettingsTitle": "Ubah Setelan Jaringan?", "changeRouterPassword": "Ubah Kata Sandi Router", "channelNumber": "Nomor kanal", + "channelAutoRecommended": "Auto (recommended)", + "channelCurrentlyUsing": "Currently using: {channel}", + "@channelCurrentlyUsing": { + "placeholders": { + "channel": { + "type": "int" + } + } + }, + "channelDfsSuffix": "· DFS", + "channelNoManualOptions": "No manual channels available for this band.", "channels": "Kanal", "checkConnectivityAndSpeed": "Periksa konektivitas dan kecepatan", "checkForUpdates": "Periksa Pembaruan", @@ -460,6 +476,8 @@ "confirmationRequired": "Konfirmasi Diperlukan", "confirmingNewFirmware": "Mengonfirmasi firmware baru sedang berjalan…", "connect": "Sambungkan", + "duplicateMacAddress": "Alamat MAC ini sudah direservasi", + "duplicateIpAddress": "Alamat IP ini sudah direservasi", "connectedDevices": "Perangkat yang Tersambung", "connecting": "Menyambungkan...", "connectingToRouter": "Menyambungkan ke router...", @@ -544,6 +562,9 @@ "diagnosticsRecWeakWifiTitle": "Sinyal WiFi Lemah", "disable": "Nonaktifkan", "disableInstantPrivacyDesc": "Semua perangkat akan dapat tersambung secara bebas ke jaringan Anda.", + "privateMacWarningTitle": "Beberapa perangkat menggunakan alamat Wi-Fi pribadi", + "privateMacWarningDesc": "Perangkat di bawah ini menggunakan alamat Wi-Fi pribadi (acak). Karena alamat ini berubah seiring waktu, perangkat tersebut mungkin diblokir setelah alamatnya berganti — bahkan perangkat yang sedang Anda gunakan sekarang. Agar tetap terhubung, nonaktifkan \"Alamat Wi-Fi Pribadi\" untuk perangkat ini sebelum mengaktifkan Privasi Instan.", + "privateMacLabel": "Pribadi", "disableInstantPrivacyTitle": "Nonaktifkan Privasi Instan?", "discards": "Pembuangan", "distribution": "Distribusi", @@ -570,7 +591,7 @@ "dynamicFrequencySelection": "Dynamic Frequency Selection (DFS)", "editPortForwarding": "Edit Penerusan Port", "editPortRangeForwarding": "Edit Penerusan Rentang Port", - "editPortTriggering": "Edit Pemicu Port", + "editPortTriggering": "Edit Pemicu Rentang Port", "editRule": "Edit aturan", "editStaticRoute": "Edit Rute Statis", "editTimeSettings": "Edit setelan waktu", @@ -594,7 +615,6 @@ "errorInvalidCredentials": "Nama pengguna atau kata sandi salah.", "errorInvalidInput": "Nilai yang dimasukkan tidak valid. Harap periksa dan coba lagi.", "errorInvalidSessionToken": "Sesi Anda tidak valid. Harap masuk lagi.", - "errorLoadingSpeedTest": "Gagal memuat tes kecepatan", "errorNetwork": "Kesalahan jaringan. Harap periksa koneksi Anda dan coba lagi.", "errorNotAuthenticated": "Anda belum masuk. Harap masuk dan coba lagi.", "errorResourceNotFound": "Setelan yang diminta tidak dapat ditemukan pada router.", @@ -763,6 +783,9 @@ "noAdvancedWifiSettings": "Tidak ada setelan WiFi lanjutan yang tersedia untuk perangkat ini.", "noAnswersReturned": "Tidak ada jawaban yang dikembalikan.", "noAppsInstalled": "Tidak ada aplikasi yang terpasang pada router ini", + "unableToLoadApps": "Tidak dapat memuat aplikasi", + "unableToLoadHealthData": "Tidak dapat memuat data kesehatan", + "unableToLoadTopology": "Tidak dapat memuat topologi", "noClients": "Tidak ada klien", "noDeviceActivityRecorded": "Tidak ada aktivitas perangkat yang tercatat", "noDevicesCurrentlyConnected": "Tidak ada perangkat yang saat ini tersambung.", @@ -780,7 +803,7 @@ "noLogFilesAvailable": "Tidak ada berkas log yang tersedia pada router ini", "noPortMappingsConfigured": "Tidak ada pemetaan port yang dikonfigurasi", "noPortRangeRules": "Tidak ada aturan penerusan rentang port yang dikonfigurasi", - "noPortTriggeringRules": "Tidak ada aturan pemicu port yang dikonfigurasi", + "noPortTriggeringRules": "Tidak ada aturan pemicu rentang port yang dikonfigurasi", "noPresetSelected": "Tidak ada preset yang dipilih", "noReservationIpMayChange": "Tidak ada reservasi. IP dapat berubah saat tersambung kembali.", "noSinglePortRules": "Tidak ada aturan penerusan port tunggal yang dikonfigurasi", @@ -836,9 +859,8 @@ "portMapping": "Pemetaan Port", "portMappingSubtitle": "Aturan penerusan port dan konfigurasi DMZ", "portMustBe1To65535": "Port harus 1-65535", - "portRangeWithCount": "Rentang Port ({count})", "portRules": "Aturan Port", - "portTriggering": "Pemicu Port", + "portTriggering": "Pemicu Rentang Port", "ports": "Port", "potentialIssues": "Potensi Masalah", "pppStatus": "Status PPP", @@ -882,6 +904,12 @@ "renew": "Perbarui", "requiredLabel": "(Wajib)", "reservationReleased": "Reservasi dilepaskan", + "reservationAdded": "Reservasi ditambahkan", + "reservationDeleted": "Reservasi dihapus", + "reconnectedToRouter": "Tersambung ulang ke router", + "timeSettingsSaved": "Setelan waktu disimpan", + "ruleAdded": "Aturan ditambahkan", + "channelUpdated": "Saluran diperbarui", "reservations": "Reservasi", "reserveIpAddress": "Reservasi Alamat IP", "reserved": "Direservasi", @@ -908,6 +936,8 @@ "routerRebootComplete": "Boot ulang router selesai", "routerWritingImage": "Router sedang menulis citra baru. Jangan matikan daya.", "rtColumn": "RT", + "unableToLoadDiagnostics": "Tidak dapat memuat diagnostik", + "unableToLoadSpeedTest": "Tidak dapat memuat tes kecepatan", "rules": "Aturan", "runAgain": "Jalankan Lagi", "runDiagnosticsTitle": "Jalankan Diagnostik", @@ -942,7 +972,6 @@ "signalQuality": "Kualitas Sinyal", "signalQualitySubtitle": "Kualitas sinyal WiFi berdasarkan pita frekuensi", "signalStrengthDbm": "{value} dBm", - "singlePortWithCount": "Port Tunggal ({count})", "singleRouterSetupNoBackhaul": "Penyiapan router tunggal — tanpa backhaul", "sizeBytes": "Ukuran: {size} byte ({mib} MiB)", "skipped": "Dilewati", @@ -1003,15 +1032,10 @@ "trend": "Tren", "trends": "Tren", "triggerPorts": "Port Pemicu", - "triggeringWithCount": "Pemicu ({count})", "txPower": "Daya Tx", "type": "Tipe", "typeAMessage": "Ketik pesan...", "unableToGatherDeviceInfo": "Tidak dapat mengumpulkan informasi perangkat", - "unableToLoadApps": "Tidak dapat memuat aplikasi", - "unableToLoadDiagnostics": "Tidak dapat memuat diagnostik", - "unableToLoadSpeedTest": "Tidak dapat memuat tes kecepatan", - "unableToLoadTopology": "Tidak dapat memuat topologi", "unchangedLabel": "(Tidak diubah)", "unknownWidget": "Widget tidak dikenal: {id}", "unnamed": "(tanpa nama)", @@ -1099,5 +1123,12 @@ "targetChain": "Rantai Target", "synchronized": "Tersinkronisasi", "noPortForwardingRulesConfigured": "Tidak ada aturan penerusan port yang dikonfigurasi", - "nItems": "{count, plural, other{{count} item}}" + "nItems": "{count, plural, other{{count} item}}", + "bridgeReconnectHint": "Setelah menyimpan, sambungkan kembali ke router Anda di {url}.", + "bridgeRedirectTitle": "Sambungkan kembali ke router Anda", + "bridgeRedirectMessage": "Router Anda sekarang menjadi bridge transparan dan tidak lagi memberikan alamat IP lokal. Sambungkan kembali di {url}.", + "bridgeRedirectButton": "Buka router", + "lanIpRedirectTitle": "Sambungkan kembali ke router Anda", + "lanIpRedirectMessage": "Alamat IP router Anda telah berubah. Sambungkan kembali ke router di {url}. Anda mungkin perlu menunggu sebentar hingga perangkat Anda memperoleh alamat baru.", + "lanIpRedirectButton": "Buka router" } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 9fdb1b64d..58bdc7b09 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -126,6 +126,8 @@ "invalidDestinationMacAddress": "Indirizzo MAC di destinazione non valido", "invalidDns": "DNS non valido", "invalidGatewayIpAddress": "Indirizzo IP del gateway non valido", + "gatewayMustBeWithinLanSubnet": "Il gateway deve essere all'interno della subnet LAN", + "gatewayMustBeOutsideLanSubnet": "Il gateway deve essere al di fuori della subnet LAN", "invalidInput": "Inserimento non valido", "invalidIpAddress": "Indirizzo IP non valido", "invalidMACAddress": "Indirizzo MAC non valido.", @@ -134,6 +136,7 @@ "ipv4": "IPv4", "ipv6": "IPv6", "ipv6Address": "Indirizzo IPv6", + "ipv6ScopeLinkLocal": "Link-local", "keepAlive": "Connessione sempre attiva", "l2tpPassthrough": "Passthrough L2TP", "labelInterface": "Interfaccia", @@ -239,9 +242,11 @@ "prefix": "Prefisso", "prefixLength": "Lunghezza del prefisso", "print": "Stampa", + "privateMac": "Privato", "privacyAndSecurity": "Privacy e sicurezza", "processing": "Elaborazione...", "protocol": "Protocollo", + "publicMac": "Pubblico", "quickSetup": "Configurazione rapida", "applyToAllBandsDesc": "Applica le stesse impostazioni WiFi a tutte le bande contemporaneamente", "rebootChildTitle": "Riavvia il nodo e tutti i suoi nodi figli", @@ -380,7 +385,7 @@ "addDeviceManually": "Aggiungi dispositivo manualmente", "addPortForwarding": "Aggiungi port forwarding", "addPortRangeForwarding": "Aggiungi inoltro intervallo di porte", - "addPortTriggering": "Aggiungi port triggering", + "addPortTriggering": "Aggiungi triggering intervallo di porte", "addedWidgetNamed": "{name} aggiunto", "adding": "Aggiunta in corso…", "additionalFiltersOnlineOnly": "I filtri aggiuntivi sono disponibili solo per i dispositivi online.", @@ -434,6 +439,17 @@ "changeNetworkSettingsTitle": "Modificare le impostazioni di rete?", "changeRouterPassword": "Modifica password del router", "channelNumber": "Numero del canale", + "channelAutoRecommended": "Auto (recommended)", + "channelCurrentlyUsing": "Currently using: {channel}", + "@channelCurrentlyUsing": { + "placeholders": { + "channel": { + "type": "int" + } + } + }, + "channelDfsSuffix": "· DFS", + "channelNoManualOptions": "No manual channels available for this band.", "channels": "Canali", "checkConnectivityAndSpeed": "Controlla connettività e velocità", "checkForUpdates": "Verifica aggiornamenti", @@ -462,6 +478,8 @@ "confirmationRequired": "Conferma richiesta", "confirmingNewFirmware": "Verifica che il nuovo firmware sia in esecuzione…", "connect": "Connetti", + "duplicateMacAddress": "Questo indirizzo MAC è già prenotato", + "duplicateIpAddress": "Questo indirizzo IP è già prenotato", "connectedDevices": "Dispositivi connessi", "connecting": "Connessione in corso...", "connectingToRouter": "Connessione al router...", @@ -546,6 +564,9 @@ "diagnosticsRecWeakWifiTitle": "Segnale WiFi debole", "disable": "Disattiva", "disableInstantPrivacyDesc": "Tutti i dispositivi potranno connettersi liberamente alla tua rete.", + "privateMacWarningTitle": "Alcuni dispositivi usano un indirizzo Wi-Fi privato", + "privateMacWarningDesc": "I dispositivi elencati di seguito usano un indirizzo Wi-Fi privato (casuale). Poiché questo indirizzo cambia nel tempo, potrebbero essere bloccati dopo la sua rotazione — compreso quello che stai usando ora. Per restare connesso, disattiva l’\"Indirizzo Wi-Fi privato\" per questi dispositivi prima di attivare Instant Privacy.", + "privateMacLabel": "Privato", "disableInstantPrivacyTitle": "Disattivare Instant Privacy?", "discards": "Scarti", "distribution": "Distribuzione", @@ -572,7 +593,7 @@ "dynamicFrequencySelection": "Selezione dinamica della frequenza (DFS)", "editPortForwarding": "Modifica port forwarding", "editPortRangeForwarding": "Modifica inoltro intervallo di porte", - "editPortTriggering": "Modifica port triggering", + "editPortTriggering": "Modifica triggering intervallo di porte", "editRule": "Modifica regola", "editStaticRoute": "Modifica route statica", "editTimeSettings": "Modifica impostazioni dell'ora", @@ -596,7 +617,6 @@ "errorInvalidCredentials": "Nome utente o password errati.", "errorInvalidInput": "Il valore inserito non è valido. Controlla e riprova.", "errorInvalidSessionToken": "La tua sessione non è valida. Accedi di nuovo.", - "errorLoadingSpeedTest": "Errore durante il caricamento dello speed test", "errorNetwork": "Errore di rete. Controlla la tua connessione e riprova.", "errorNotAuthenticated": "Non hai effettuato l'accesso. Accedi e riprova.", "errorResourceNotFound": "Impossibile trovare l'impostazione richiesta sul router.", @@ -763,6 +783,9 @@ "noAdvancedWifiSettings": "Nessuna impostazione WiFi avanzata disponibile per questo dispositivo.", "noAnswersReturned": "Nessuna risposta restituita.", "noAppsInstalled": "Nessuna app installata su questo router", + "unableToLoadApps": "Impossibile caricare le app", + "unableToLoadHealthData": "Impossibile caricare i dati sullo stato", + "unableToLoadTopology": "Impossibile caricare la topologia", "noClients": "Nessun client", "noDeviceActivityRecorded": "Nessuna attività del dispositivo registrata", "noDevicesCurrentlyConnected": "Nessun dispositivo attualmente connesso.", @@ -780,7 +803,7 @@ "noLogFilesAvailable": "Nessun file di log disponibile su questo router", "noPortMappingsConfigured": "Nessuna mappatura delle porte configurata", "noPortRangeRules": "Nessuna regola di inoltro intervallo di porte configurata", - "noPortTriggeringRules": "Nessuna regola di port triggering configurata", + "noPortTriggeringRules": "Nessuna regola di triggering intervallo di porte configurata", "noPresetSelected": "Nessun preset selezionato", "noReservationIpMayChange": "Nessuna prenotazione. L'IP potrebbe cambiare alla riconnessione.", "noSinglePortRules": "Nessuna regola di inoltro porta singola configurata", @@ -836,9 +859,8 @@ "portMapping": "Mappatura porte", "portMappingSubtitle": "Regole di port forwarding e configurazione DMZ", "portMustBe1To65535": "La porta deve essere compresa tra 1 e 65535", - "portRangeWithCount": "Intervallo di porte ({count})", "portRules": "Regole porte", - "portTriggering": "Port triggering", + "portTriggering": "Triggering intervallo di porte", "ports": "Porte", "potentialIssues": "Potenziali problemi", "pppStatus": "Stato PPP", @@ -882,6 +904,12 @@ "renew": "Rinnova", "requiredLabel": "(Obbligatorio)", "reservationReleased": "Prenotazione rilasciata", + "reservationAdded": "Prenotazione aggiunta", + "reservationDeleted": "Prenotazione eliminata", + "reconnectedToRouter": "Riconnesso al router", + "timeSettingsSaved": "Impostazioni dell'ora salvate", + "ruleAdded": "Regola aggiunta", + "channelUpdated": "Canale aggiornato", "reservations": "Prenotazioni", "reserveIpAddress": "Prenota indirizzo IP", "reserved": "Prenotato", @@ -908,6 +936,8 @@ "routerRebootComplete": "Riavvio del router completato", "routerWritingImage": "Il router sta scrivendo la nuova immagine. Non spegnere.", "rtColumn": "RT", + "unableToLoadDiagnostics": "Impossibile caricare la diagnostica", + "unableToLoadSpeedTest": "Impossibile caricare lo speed test", "rules": "Regole", "runAgain": "Esegui di nuovo", "runDiagnosticsTitle": "Esegui diagnostica", @@ -942,7 +972,6 @@ "signalQuality": "Qualità del segnale", "signalQualitySubtitle": "Qualità del segnale WiFi per banda di frequenza", "signalStrengthDbm": "{value} dBm", - "singlePortWithCount": "Porta singola ({count})", "singleRouterSetupNoBackhaul": "Configurazione a router singolo — nessun backhaul", "sizeBytes": "Dimensione: {size} byte ({mib} MiB)", "skipped": "Saltato", @@ -1003,15 +1032,10 @@ "trend": "Andamento", "trends": "Andamenti", "triggerPorts": "Porte di trigger", - "triggeringWithCount": "Triggering ({count})", "txPower": "Potenza Tx", "type": "Tipo", "typeAMessage": "Scrivi un messaggio...", "unableToGatherDeviceInfo": "Impossibile raccogliere le informazioni sul dispositivo", - "unableToLoadApps": "Impossibile caricare le app", - "unableToLoadDiagnostics": "Impossibile caricare la diagnostica", - "unableToLoadSpeedTest": "Impossibile caricare lo speed test", - "unableToLoadTopology": "Impossibile caricare la topologia", "unchangedLabel": "(Invariato)", "unknownWidget": "Widget sconosciuto: {id}", "unnamed": "(senza nome)", @@ -1099,5 +1123,12 @@ "targetChain": "Catena di destinazione", "synchronized": "Sincronizzato", "noPortForwardingRulesConfigured": "Nessuna regola di port forwarding configurata", - "nItems": "{count, plural, one{{count} elemento} other{{count} elementi}}" + "nItems": "{count, plural, one{{count} elemento} other{{count} elementi}}", + "bridgeReconnectHint": "Dopo il salvataggio, riconnettiti al router all'indirizzo {url}.", + "bridgeRedirectTitle": "Riconnettiti al router", + "bridgeRedirectMessage": "Il router ora funziona come bridge trasparente e non assegna più indirizzi IP locali. Riconnettiti all'indirizzo {url}.", + "bridgeRedirectButton": "Vai al router", + "lanIpRedirectTitle": "Riconnettiti al router", + "lanIpRedirectMessage": "L'indirizzo IP del router è cambiato. Riconnettiti a esso all'indirizzo {url}. Potrebbe essere necessario attendere un momento affinché il dispositivo ottenga un nuovo indirizzo.", + "lanIpRedirectButton": "Vai al router" } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 97c4c51b3..d9b728393 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -126,6 +126,8 @@ "invalidDestinationMacAddress": "無効な宛先MACアドレス", "invalidDns": "無効なDNSです。", "invalidGatewayIpAddress": "無効なゲートウェイIPアドレス", + "gatewayMustBeWithinLanSubnet": "ゲートウェイはLANサブネット内である必要があります", + "gatewayMustBeOutsideLanSubnet": "ゲートウェイはLANサブネット外である必要があります", "invalidInput": "無効な入力です。", "invalidIpAddress": "無効なIPアドレスです。", "invalidMACAddress": "無効な MAC アドレスです。", @@ -134,6 +136,7 @@ "ipv4": "IPv4", "ipv6": "IPv6", "ipv6Address": "IPv6 アドレス", + "ipv6ScopeLinkLocal": "リンクローカル", "keepAlive": "接続を維持", "l2tpPassthrough": "L2TP パススルー", "labelInterface": "インターフェイス", @@ -240,9 +243,11 @@ "prefix": "プレフィックス", "prefixLength": "プレフィックスの長さ", "print": "印刷", + "privateMac": "プライベート", "privacyAndSecurity": "プライバシーとセキュリティ", "processing": "処理中...", "protocol": "プロトコル", + "publicMac": "パブリック", "quickSetup": "クイック設定", "applyToAllBandsDesc": "同じ WiFi 設定をすべてのバンドに一度に適用する", "rebootChildTitle": "ノードとそのすべての子ノードを再起動", @@ -381,7 +386,7 @@ "addDeviceManually": "デバイスを手動で追加", "addPortForwarding": "ポート転送を追加", "addPortRangeForwarding": "ポート範囲転送を追加", - "addPortTriggering": "ポートトリガーを追加", + "addPortTriggering": "ポート範囲トリガーを追加", "addedWidgetNamed": "{name} を追加しました", "adding": "追加中…", "additionalFiltersOnlineOnly": "追加のフィルターはオンラインのデバイスでのみ利用できます。", @@ -435,6 +440,17 @@ "changeNetworkSettingsTitle": "ネットワーク設定を変更しますか?", "changeRouterPassword": "ルーターのパスワードを変更", "channelNumber": "チャンネル番号", + "channelAutoRecommended": "Auto (recommended)", + "channelCurrentlyUsing": "Currently using: {channel}", + "@channelCurrentlyUsing": { + "placeholders": { + "channel": { + "type": "int" + } + } + }, + "channelDfsSuffix": "· DFS", + "channelNoManualOptions": "No manual channels available for this band.", "channels": "チャンネル", "checkConnectivityAndSpeed": "接続と速度を確認", "checkForUpdates": "更新を確認", @@ -463,6 +479,8 @@ "confirmationRequired": "確認が必要です", "confirmingNewFirmware": "新しいファームウェアが動作していることを確認中…", "connect": "接続", + "duplicateMacAddress": "この MAC アドレスはすでに予約されています", + "duplicateIpAddress": "この IP アドレスはすでに予約されています", "connectedDevices": "接続中のデバイス", "connecting": "接続中...", "connectingToRouter": "ルーターに接続中...", @@ -547,6 +565,9 @@ "diagnosticsRecWeakWifiTitle": "WiFi 信号が弱い", "disable": "無効にする", "disableInstantPrivacyDesc": "すべてのデバイスがネットワークに自由に接続できるようになります。", + "privateMacWarningTitle": "一部のデバイスはプライベート Wi-Fi アドレスを使用しています", + "privateMacWarningDesc": "以下のデバイスはプライベート(ランダム化された)Wi-Fi アドレスを使用しています。このアドレスは時間の経過とともに変化するため、切り替わった後にブロックされる可能性があります。現在使用しているデバイスも例外ではありません。接続を維持するには、Instant Privacy を有効にする前に、これらのデバイスの「プライベート Wi-Fi アドレス」をオフにしてください。", + "privateMacLabel": "プライベート", "disableInstantPrivacyTitle": "Instant Privacy を無効にしますか?", "discards": "破棄", "distribution": "分布", @@ -573,7 +594,7 @@ "dynamicFrequencySelection": "動的周波数選択 (DFS)", "editPortForwarding": "ポート転送を編集", "editPortRangeForwarding": "ポート範囲転送を編集", - "editPortTriggering": "ポートトリガーを編集", + "editPortTriggering": "ポート範囲トリガーを編集", "editRule": "ルールを編集", "editStaticRoute": "静的ルートを編集", "editTimeSettings": "時刻設定を編集", @@ -597,7 +618,6 @@ "errorInvalidCredentials": "ユーザー名またはパスワードが正しくありません。", "errorInvalidInput": "入力された値が無効です。確認して再度お試しください。", "errorInvalidSessionToken": "セッションが無効です。再度サインインしてください。", - "errorLoadingSpeedTest": "速度テストの読み込みエラー", "errorNetwork": "ネットワークエラーです。接続を確認して再度お試しください。", "errorNotAuthenticated": "サインインしていません。サインインして再度お試しください。", "errorResourceNotFound": "要求された設定がルーター上に見つかりませんでした。", @@ -763,6 +783,9 @@ "noAdvancedWifiSettings": "このデバイスで利用できる詳細な WiFi 設定はありません。", "noAnswersReturned": "応答が返されませんでした。", "noAppsInstalled": "このルーターにインストールされているアプリはありません", + "unableToLoadApps": "アプリを読み込めません", + "unableToLoadHealthData": "ヘルスデータを読み込めません", + "unableToLoadTopology": "トポロジーを読み込めません", "noClients": "クライアントなし", "noDeviceActivityRecorded": "記録されたデバイスアクティビティはありません", "noDevicesCurrentlyConnected": "現在接続中のデバイスはありません。", @@ -780,7 +803,7 @@ "noLogFilesAvailable": "このルーターに利用可能なログファイルはありません", "noPortMappingsConfigured": "ポートマッピングが設定されていません", "noPortRangeRules": "ポート範囲転送ルールが設定されていません", - "noPortTriggeringRules": "ポートトリガールールが設定されていません", + "noPortTriggeringRules": "ポート範囲トリガールールが設定されていません", "noPresetSelected": "プリセットが選択されていません", "noReservationIpMayChange": "予約なし。再接続時に IP が変わる場合があります。", "noSinglePortRules": "単一ポート転送ルールが設定されていません", @@ -836,9 +859,8 @@ "portMapping": "ポートマッピング", "portMappingSubtitle": "ポート転送ルールと DMZ 設定", "portMustBe1To65535": "ポートは 1〜65535 である必要があります", - "portRangeWithCount": "ポート範囲 ({count})", "portRules": "ポートルール", - "portTriggering": "ポートトリガー", + "portTriggering": "ポート範囲トリガー", "ports": "ポート", "potentialIssues": "潜在的な問題", "pppStatus": "PPP ステータス", @@ -882,6 +904,12 @@ "renew": "更新", "requiredLabel": "(必須)", "reservationReleased": "予約を解放しました", + "reservationAdded": "予約を追加しました", + "reservationDeleted": "予約を削除しました", + "reconnectedToRouter": "ルーターに再接続しました", + "timeSettingsSaved": "時刻設定を保存しました", + "ruleAdded": "ルールを追加しました", + "channelUpdated": "チャネルを更新しました", "reservations": "予約", "reserveIpAddress": "IP アドレスを予約", "reserved": "予約済み", @@ -908,6 +936,8 @@ "routerRebootComplete": "ルーターの再起動が完了しました", "routerWritingImage": "ルーターが新しいイメージを書き込んでいます。電源を切らないでください。", "rtColumn": "RT", + "unableToLoadDiagnostics": "診断を読み込めません", + "unableToLoadSpeedTest": "スピードテストを読み込めません", "rules": "ルール", "runAgain": "再実行", "runDiagnosticsTitle": "診断を実行", @@ -942,7 +972,6 @@ "signalQuality": "信号品質", "signalQualitySubtitle": "周波数バンド別の WiFi 信号品質", "signalStrengthDbm": "{value} dBm", - "singlePortWithCount": "単一ポート ({count})", "singleRouterSetupNoBackhaul": "単一ルーター構成 — バックホールなし", "sizeBytes": "サイズ: {size} バイト ({mib} MiB)", "skipped": "スキップ", @@ -1003,15 +1032,10 @@ "trend": "傾向", "trends": "傾向", "triggerPorts": "トリガーポート", - "triggeringWithCount": "トリガー ({count})", "txPower": "送信電力", "type": "タイプ", "typeAMessage": "メッセージを入力...", "unableToGatherDeviceInfo": "デバイス情報を取得できません", - "unableToLoadApps": "アプリを読み込めません", - "unableToLoadDiagnostics": "診断を読み込めません", - "unableToLoadSpeedTest": "速度テストを読み込めません", - "unableToLoadTopology": "トポロジーを読み込めません", "unchangedLabel": "(変更なし)", "unknownWidget": "不明なウィジェット: {id}", "unnamed": "(名前なし)", @@ -1099,5 +1123,12 @@ "targetChain": "ターゲットチェーン", "synchronized": "同期済み", "noPortForwardingRulesConfigured": "ポート転送ルールが設定されていません", - "nItems": "{count, plural, other{{count} 個の項目}}" + "nItems": "{count, plural, other{{count} 個の項目}}", + "bridgeReconnectHint": "保存後、{url} からルーターに再接続してください。", + "bridgeRedirectTitle": "ルーターに再接続", + "bridgeRedirectMessage": "ルーターは現在、透過的なブリッジとして動作しており、ローカル IP アドレスを割り当てません。{url} から再接続してください。", + "bridgeRedirectButton": "ルーターに移動", + "lanIpRedirectTitle": "ルーターに再接続", + "lanIpRedirectMessage": "ルーターの IP アドレスが変更されました。{url} から再接続してください。デバイスが新しいアドレスを取得するまで少し時間がかかる場合があります。", + "lanIpRedirectButton": "ルーターに移動" } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 5625399be..01aec103d 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -126,6 +126,8 @@ "invalidDestinationMacAddress": "잘못된 대상 MAC 주소", "invalidDns": "잘못된 DNS", "invalidGatewayIpAddress": "잘못된 게이트웨이 IP 주소", + "gatewayMustBeWithinLanSubnet": "게이트웨이는 LAN 서브넷 내에 있어야 합니다", + "gatewayMustBeOutsideLanSubnet": "게이트웨이는 LAN 서브넷 외부에 있어야 합니다", "invalidInput": "잘못된 입력", "invalidIpAddress": "잘못된 IP 주소", "invalidMACAddress": "잘못된 MAC 주소입니다.", @@ -134,6 +136,7 @@ "ipv4": "IPv4", "ipv6": "IPv6", "ipv6Address": "IPv6 주소", + "ipv6ScopeLinkLocal": "링크-로컬", "keepAlive": "상태 유지", "l2tpPassthrough": "L2TP 패스스루", "labelInterface": "인터페이스", @@ -237,9 +240,11 @@ "prefix": "접두사", "prefixLength": "접두사 길이", "print": "인쇄", + "privateMac": "비공개", "privacyAndSecurity": "개인정보 보호 및 보안", "processing": "처리 중...", "protocol": "프로토콜", + "publicMac": "공개", "quickSetup": "빠른 설정", "applyToAllBandsDesc": "동일한 WiFi 설정을 모든 대역에 한 번에 적용", "rebootChildTitle": "노드 및 모든 하위 노드 재부팅", @@ -378,7 +383,7 @@ "addDeviceManually": "장치 수동 추가", "addPortForwarding": "포트 포워딩 추가", "addPortRangeForwarding": "포트 범위 포워딩 추가", - "addPortTriggering": "포트 트리거링 추가", + "addPortTriggering": "포트 범위 트리거링 추가", "addedWidgetNamed": "{name} 추가됨", "adding": "추가하는 중…", "additionalFiltersOnlineOnly": "추가 필터는 온라인 장치에서만 사용할 수 있습니다.", @@ -432,6 +437,17 @@ "changeNetworkSettingsTitle": "네트워크 설정을 변경하시겠습니까?", "changeRouterPassword": "라우터 암호 변경", "channelNumber": "채널 번호", + "channelAutoRecommended": "Auto (recommended)", + "channelCurrentlyUsing": "Currently using: {channel}", + "@channelCurrentlyUsing": { + "placeholders": { + "channel": { + "type": "int" + } + } + }, + "channelDfsSuffix": "· DFS", + "channelNoManualOptions": "No manual channels available for this band.", "channels": "채널", "checkConnectivityAndSpeed": "연결 및 속도 확인", "checkForUpdates": "업데이트 확인", @@ -460,6 +476,8 @@ "confirmationRequired": "확인 필요", "confirmingNewFirmware": "새 펌웨어가 실행 중인지 확인하는 중…", "connect": "연결", + "duplicateMacAddress": "이 MAC 주소는 이미 예약되어 있습니다", + "duplicateIpAddress": "이 IP 주소는 이미 예약되어 있습니다", "connectedDevices": "연결된 장치", "connecting": "연결 중...", "connectingToRouter": "라우터에 연결 중...", @@ -544,6 +562,9 @@ "diagnosticsRecWeakWifiTitle": "약한 WiFi 신호", "disable": "비활성화", "disableInstantPrivacyDesc": "모든 장치가 네트워크에 자유롭게 연결할 수 있게 됩니다.", + "privateMacWarningTitle": "일부 기기가 비공개 Wi-Fi 주소를 사용합니다", + "privateMacWarningDesc": "아래 기기는 비공개(무작위) Wi-Fi 주소를 사용합니다. 이 주소는 시간이 지나면 변경되므로 주소가 바뀐 후에는 차단될 수 있으며, 지금 사용 중인 기기도 예외가 아닙니다. 계속 연결하려면 Instant Privacy를 활성화하기 전에 이 기기들의 “비공개 Wi-Fi 주소”를 꺼 주세요.", + "privateMacLabel": "비공개", "disableInstantPrivacyTitle": "Instant Privacy를 비활성화하시겠습니까?", "discards": "폐기", "distribution": "분포", @@ -570,7 +591,7 @@ "dynamicFrequencySelection": "동적 주파수 선택(DFS)", "editPortForwarding": "포트 포워딩 편집", "editPortRangeForwarding": "포트 범위 포워딩 편집", - "editPortTriggering": "포트 트리거링 편집", + "editPortTriggering": "포트 범위 트리거링 편집", "editRule": "규칙 편집", "editStaticRoute": "고정 경로 편집", "editTimeSettings": "시간 설정 편집", @@ -594,7 +615,6 @@ "errorInvalidCredentials": "사용자 이름 또는 암호가 잘못되었습니다.", "errorInvalidInput": "입력한 값이 유효하지 않습니다. 확인 후 다시 시도하세요.", "errorInvalidSessionToken": "세션이 유효하지 않습니다. 다시 로그인하세요.", - "errorLoadingSpeedTest": "속도 테스트 로드 중 오류", "errorNetwork": "네트워크 오류입니다. 연결을 확인하고 다시 시도하세요.", "errorNotAuthenticated": "로그인되어 있지 않습니다. 로그인 후 다시 시도하세요.", "errorResourceNotFound": "요청한 설정을 라우터에서 찾을 수 없습니다.", @@ -763,6 +783,9 @@ "noAdvancedWifiSettings": "이 장치에 사용할 수 있는 고급 WiFi 설정이 없습니다.", "noAnswersReturned": "반환된 응답이 없습니다.", "noAppsInstalled": "이 라우터에 설치된 앱이 없습니다", + "unableToLoadApps": "앱을 로드할 수 없습니다", + "unableToLoadHealthData": "상태 데이터를 로드할 수 없습니다", + "unableToLoadTopology": "토폴로지를 로드할 수 없습니다", "noClients": "클라이언트 없음", "noDeviceActivityRecorded": "기록된 장치 활동이 없습니다", "noDevicesCurrentlyConnected": "현재 연결된 장치가 없습니다.", @@ -780,7 +803,7 @@ "noLogFilesAvailable": "이 라우터에 사용할 수 있는 로그 파일이 없습니다", "noPortMappingsConfigured": "구성된 포트 매핑이 없습니다", "noPortRangeRules": "구성된 포트 범위 포워딩 규칙이 없습니다", - "noPortTriggeringRules": "구성된 포트 트리거링 규칙이 없습니다", + "noPortTriggeringRules": "구성된 포트 범위 트리거링 규칙 없음", "noPresetSelected": "선택된 사전 설정이 없습니다", "noReservationIpMayChange": "예약 없음. 다시 연결하면 IP가 변경될 수 있습니다.", "noSinglePortRules": "구성된 단일 포트 포워딩 규칙이 없습니다", @@ -836,9 +859,8 @@ "portMapping": "포트 매핑", "portMappingSubtitle": "포트 포워딩 규칙 및 DMZ 구성", "portMustBe1To65535": "포트는 1-65535여야 합니다", - "portRangeWithCount": "포트 범위 ({count})", "portRules": "포트 규칙", - "portTriggering": "포트 트리거링", + "portTriggering": "포트 범위 트리거링", "ports": "포트", "potentialIssues": "잠재적 문제", "pppStatus": "PPP 상태", @@ -882,6 +904,12 @@ "renew": "갱신", "requiredLabel": "(필수)", "reservationReleased": "예약이 해제됨", + "reservationAdded": "예약이 추가됨", + "reservationDeleted": "예약이 삭제됨", + "reconnectedToRouter": "라우터에 다시 연결됨", + "timeSettingsSaved": "시간 설정이 저장됨", + "ruleAdded": "규칙이 추가됨", + "channelUpdated": "채널이 업데이트됨", "reservations": "예약", "reserveIpAddress": "IP 주소 예약", "reserved": "예약됨", @@ -908,6 +936,8 @@ "routerRebootComplete": "라우터 재부팅 완료", "routerWritingImage": "라우터가 새 이미지를 기록 중입니다. 전원을 끄지 마세요.", "rtColumn": "RT", + "unableToLoadDiagnostics": "진단을 로드할 수 없습니다", + "unableToLoadSpeedTest": "속도 테스트를 로드할 수 없습니다", "rules": "규칙", "runAgain": "다시 실행", "runDiagnosticsTitle": "진단 실행", @@ -942,7 +972,6 @@ "signalQuality": "신호 품질", "signalQualitySubtitle": "주파수 대역별 WiFi 신호 품질", "signalStrengthDbm": "{value} dBm", - "singlePortWithCount": "단일 포트 ({count})", "singleRouterSetupNoBackhaul": "단일 라우터 설정 — 백홀 없음", "sizeBytes": "크기: {size} bytes ({mib} MiB)", "skipped": "건너뜀", @@ -1003,15 +1032,10 @@ "trend": "추세", "trends": "추세", "triggerPorts": "트리거 포트", - "triggeringWithCount": "트리거링 ({count})", "txPower": "Tx 출력", "type": "유형", "typeAMessage": "메시지를 입력하세요...", "unableToGatherDeviceInfo": "장치 정보를 수집할 수 없습니다", - "unableToLoadApps": "앱을 로드할 수 없습니다", - "unableToLoadDiagnostics": "진단을 로드할 수 없습니다", - "unableToLoadSpeedTest": "속도 테스트를 로드할 수 없습니다", - "unableToLoadTopology": "토폴로지를 로드할 수 없습니다", "unchangedLabel": "(변경되지 않음)", "unknownWidget": "알 수 없는 위젯: {id}", "unnamed": "(이름 없음)", @@ -1099,5 +1123,12 @@ "targetChain": "대상 체인", "synchronized": "동기화됨", "noPortForwardingRulesConfigured": "구성된 포트 포워딩 규칙이 없습니다", - "nItems": "{count, plural, other{{count}개 항목}}" + "nItems": "{count, plural, other{{count}개 항목}}", + "bridgeReconnectHint": "저장한 후 {url} 에서 라우터에 다시 연결하세요.", + "bridgeRedirectTitle": "라우터에 다시 연결", + "bridgeRedirectMessage": "라우터가 이제 투명 브리지로 작동하며 로컬 IP 주소를 더 이상 할당하지 않습니다. {url} 에서 다시 연결하세요.", + "bridgeRedirectButton": "라우터로 이동", + "lanIpRedirectTitle": "라우터에 다시 연결", + "lanIpRedirectMessage": "라우터의 IP 주소가 변경되었습니다. {url}에서 다시 연결하세요. 기기가 새 주소를 받을 때까지 잠시 기다려야 할 수 있습니다.", + "lanIpRedirectButton": "라우터로 이동" } diff --git a/lib/l10n/app_nb.arb b/lib/l10n/app_nb.arb index fb18fd758..310fe67a8 100644 --- a/lib/l10n/app_nb.arb +++ b/lib/l10n/app_nb.arb @@ -126,6 +126,8 @@ "invalidDestinationMacAddress": "Ugyldig destinasjons-MAC-adresse", "invalidDns": "Ugyldig DNS", "invalidGatewayIpAddress": "Ugyldig gateway-IP-adresse", + "gatewayMustBeWithinLanSubnet": "Gateway må være innenfor LAN-undernett", + "gatewayMustBeOutsideLanSubnet": "Gateway må være utenfor LAN-undernett", "invalidInput": "Ugyldige inndata", "invalidIpAddress": "Ugyldig IP-adresse", "invalidMACAddress": "Ugyldig MAC-adresse.", @@ -134,6 +136,7 @@ "ipv4": "IPV4", "ipv6": "IPV6", "ipv6Address": "IPv6-adresse", + "ipv6ScopeLinkLocal": "Lenkelokal", "keepAlive": "Oppretthold", "l2tpPassthrough": "L2TP-passasje", "labelInterface": "Grensesnitt", @@ -239,9 +242,11 @@ "prefix": "Prefiks", "prefixLength": "Lengde på prefikset", "print": "Skriv ut", + "privateMac": "Privat", "privacyAndSecurity": "Databeskyttelse og sikkerhet", "processing": "Behandling...", "protocol": "Protokoll", + "publicMac": "Offentlig", "quickSetup": "Rask oppsett", "applyToAllBandsDesc": "Bruk de samme WiFi-innstillingene på alle bånd samtidig", "rebootChildTitle": "Start noden og alle dens underordnede på nytt", @@ -380,7 +385,7 @@ "addDeviceManually": "Legg til enhet manuelt", "addPortForwarding": "Legg til portvideresending", "addPortRangeForwarding": "Legg til videresending av portområde", - "addPortTriggering": "Legg til portutløsing", + "addPortTriggering": "Legg til portområdeutløsing", "addedWidgetNamed": "La til {name}", "adding": "Legger til…", "additionalFiltersOnlineOnly": "Flere filtre er bare tilgjengelige for tilkoblede enheter.", @@ -434,6 +439,17 @@ "changeNetworkSettingsTitle": "Endre nettverksinnstillinger?", "changeRouterPassword": "Endre ruterpassord", "channelNumber": "Kanalnummer", + "channelAutoRecommended": "Auto (recommended)", + "channelCurrentlyUsing": "Currently using: {channel}", + "@channelCurrentlyUsing": { + "placeholders": { + "channel": { + "type": "int" + } + } + }, + "channelDfsSuffix": "· DFS", + "channelNoManualOptions": "No manual channels available for this band.", "channels": "Kanaler", "checkConnectivityAndSpeed": "Sjekk tilkobling og hastighet", "checkForUpdates": "Se etter oppdateringer", @@ -462,6 +478,8 @@ "confirmationRequired": "Bekreftelse kreves", "confirmingNewFirmware": "Bekrefter at den nye fastvaren kjører…", "connect": "Koble til", + "duplicateMacAddress": "Denne MAC-adressen er allerede reservert", + "duplicateIpAddress": "Denne IP-adressen er allerede reservert", "connectedDevices": "Tilkoblede enheter", "connecting": "Kobler til...", "connectingToRouter": "Kobler til ruteren...", @@ -546,6 +564,9 @@ "diagnosticsRecWeakWifiTitle": "Svakt WiFi-signal", "disable": "Deaktiver", "disableInstantPrivacyDesc": "Alle enheter vil kunne koble til nettverket ditt fritt.", + "privateMacWarningTitle": "Noen enheter bruker en privat Wi-Fi-adresse", + "privateMacWarningDesc": "Enhetene nedenfor bruker en privat (tilfeldig) Wi-Fi-adresse. Siden denne adressen endrer seg over tid, kan de bli blokkert etter at den roterer — også den du bruker akkurat nå. For å forbli tilkoblet må du slå av \"Privat Wi-Fi-adresse\" for disse enhetene før du aktiverer Instant Privacy.", + "privateMacLabel": "Privat", "disableInstantPrivacyTitle": "Deaktivere Instant Privacy?", "discards": "Forkastninger", "distribution": "Fordeling", @@ -572,7 +593,7 @@ "dynamicFrequencySelection": "Dynamic Frequency Selection (DFS)", "editPortForwarding": "Rediger portvideresending", "editPortRangeForwarding": "Rediger videresending av portområde", - "editPortTriggering": "Rediger portutløsing", + "editPortTriggering": "Rediger portområdeutløsing", "editRule": "Rediger regel", "editStaticRoute": "Rediger statisk rute", "editTimeSettings": "Rediger tidsinnstillinger", @@ -596,7 +617,6 @@ "errorInvalidCredentials": "Feil brukernavn eller passord.", "errorInvalidInput": "Den angitte verdien er ikke gyldig. Sjekk og prøv igjen.", "errorInvalidSessionToken": "Økten din er ugyldig. Logg inn på nytt.", - "errorLoadingSpeedTest": "Feil ved innlasting av hastighetstest", "errorNetwork": "Nettverksfeil. Sjekk tilkoblingen din og prøv igjen.", "errorNotAuthenticated": "Du er ikke logget inn. Logg inn og prøv igjen.", "errorResourceNotFound": "Den forespurte innstillingen ble ikke funnet på ruteren.", @@ -763,6 +783,9 @@ "noAdvancedWifiSettings": "Ingen avanserte WiFi-innstillinger tilgjengelig for denne enheten.", "noAnswersReturned": "Ingen svar returnert.", "noAppsInstalled": "Ingen apper installert på denne ruteren", + "unableToLoadApps": "Kan ikke laste inn apper", + "unableToLoadHealthData": "Kan ikke laste inn tilstandsdata", + "unableToLoadTopology": "Kan ikke laste inn topologi", "noClients": "Ingen klienter", "noDeviceActivityRecorded": "Ingen enhetsaktivitet registrert", "noDevicesCurrentlyConnected": "Ingen enheter er tilkoblet for øyeblikket.", @@ -780,7 +803,7 @@ "noLogFilesAvailable": "Ingen loggfiler tilgjengelig på denne ruteren", "noPortMappingsConfigured": "Ingen portkoblinger konfigurert", "noPortRangeRules": "Ingen regler for videresending av portområde konfigurert", - "noPortTriggeringRules": "Ingen portutløsingsregler konfigurert", + "noPortTriggeringRules": "Ingen portområdeutløsingsregler konfigurert", "noPresetSelected": "Ingen forhåndsinnstilling valgt", "noReservationIpMayChange": "Ingen reservasjon. IP kan endres ved ny tilkobling.", "noSinglePortRules": "Ingen regler for enkeltportvideresending konfigurert", @@ -836,9 +859,8 @@ "portMapping": "Portkobling", "portMappingSubtitle": "Regler for portvideresending og DMZ-konfigurasjon", "portMustBe1To65535": "Porten må være 1-65535", - "portRangeWithCount": "Portområde ({count})", "portRules": "Portregler", - "portTriggering": "Portutløsing", + "portTriggering": "Portområdeutløsing", "ports": "Porter", "potentialIssues": "Potensielle problemer", "pppStatus": "PPP-status", @@ -882,6 +904,12 @@ "renew": "Forny", "requiredLabel": "(Påkrevd)", "reservationReleased": "Reservasjon frigitt", + "reservationAdded": "Reservasjon lagt til", + "reservationDeleted": "Reservasjon slettet", + "reconnectedToRouter": "Koblet til ruteren på nytt", + "timeSettingsSaved": "Tidsinnstillinger lagret", + "ruleAdded": "Regel lagt til", + "channelUpdated": "Kanal oppdatert", "reservations": "Reservasjoner", "reserveIpAddress": "Reserver IP-adresse", "reserved": "Reservert", @@ -908,6 +936,8 @@ "routerRebootComplete": "Ruteromstart fullført", "routerWritingImage": "Ruteren skriver det nye bildet. Ikke slå av.", "rtColumn": "RT", + "unableToLoadDiagnostics": "Kan ikke laste inn diagnostikk", + "unableToLoadSpeedTest": "Kan ikke laste inn hastighetstest", "rules": "Regler", "runAgain": "Kjør på nytt", "runDiagnosticsTitle": "Kjør diagnostikk", @@ -942,7 +972,6 @@ "signalQuality": "Signalkvalitet", "signalQualitySubtitle": "WiFi-signalkvalitet etter frekvensbånd", "signalStrengthDbm": "{value} dBm", - "singlePortWithCount": "Enkeltport ({count})", "singleRouterSetupNoBackhaul": "Enkeltruteroppsett – ingen backhaul", "sizeBytes": "Størrelse: {size} byte ({mib} MiB)", "skipped": "Hoppet over", @@ -1003,15 +1032,10 @@ "trend": "Trend", "trends": "Trender", "triggerPorts": "Utløserporter", - "triggeringWithCount": "Utløsing ({count})", "txPower": "Tx-effekt", "type": "Type", "typeAMessage": "Skriv en melding...", "unableToGatherDeviceInfo": "Kan ikke hente enhetsinformasjon", - "unableToLoadApps": "Kan ikke laste inn apper", - "unableToLoadDiagnostics": "Kan ikke laste inn diagnostikk", - "unableToLoadSpeedTest": "Kan ikke laste inn hastighetstest", - "unableToLoadTopology": "Kan ikke laste inn topologi", "unchangedLabel": "(Uendret)", "unknownWidget": "Ukjent widget: {id}", "unnamed": "(uten navn)", @@ -1099,5 +1123,12 @@ "targetChain": "Målkjede", "synchronized": "Synkronisert", "noPortForwardingRulesConfigured": "Ingen videresendingsregler for porter er konfigurert", - "nItems": "{count, plural, one{{count} element} other{{count} elementer}}" + "nItems": "{count, plural, one{{count} element} other{{count} elementer}}", + "bridgeReconnectHint": "Etter lagring kobler du til ruteren din på nytt på {url}.", + "bridgeRedirectTitle": "Koble til ruteren på nytt", + "bridgeRedirectMessage": "Ruteren din fungerer nå som en transparent bro og tildeler ikke lenger lokale IP-adresser. Koble til på nytt på {url}.", + "bridgeRedirectButton": "Gå til ruteren", + "lanIpRedirectTitle": "Koble til ruteren på nytt", + "lanIpRedirectMessage": "Ruterens IP-adresse er endret. Koble til den på nytt på {url}. Du må kanskje vente et øyeblikk til enheten får en ny adresse.", + "lanIpRedirectButton": "Gå til ruteren" } diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index a6df11a85..03e41cd3f 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -126,6 +126,8 @@ "invalidDestinationMacAddress": "Ongeldig bestemmings-MAC-adres", "invalidDns": "Ongeldige DNS", "invalidGatewayIpAddress": "Ongeldig gateway-IP-adres", + "gatewayMustBeWithinLanSubnet": "Gateway moet binnen het LAN-subnet zijn", + "gatewayMustBeOutsideLanSubnet": "Gateway moet buiten het LAN-subnet zijn", "invalidInput": "Ongeldige invoer", "invalidIpAddress": "Ongeldig IP-adres", "invalidMACAddress": "Ongeldig MAC-adres.", @@ -134,6 +136,7 @@ "ipv4": "IPv4", "ipv6": "IPv6", "ipv6Address": "IPv6-adres", + "ipv6ScopeLinkLocal": "Link-lokaal", "keepAlive": "Continu verbinding houden", "l2tpPassthrough": "L2TP-doorvoer", "labelInterface": "Interface", @@ -239,9 +242,11 @@ "prefix": "Prefix", "prefixLength": "Prefixlengte", "print": "Afdrukken", + "privateMac": "Privé", "privacyAndSecurity": "Privacy en beveiliging", "processing": "Verwerken...", "protocol": "Protocol", + "publicMac": "Openbaar", "quickSetup": "Snelle installatie", "applyToAllBandsDesc": "Pas dezelfde WiFi-instellingen tegelijkertijd toe op alle banden", "rebootChildTitle": "Herstart de node en al zijn kind-/kinderen", @@ -380,7 +385,7 @@ "addDeviceManually": "Apparaat handmatig toevoegen", "addPortForwarding": "Port forwarding toevoegen", "addPortRangeForwarding": "Poortbereik forwarding toevoegen", - "addPortTriggering": "Port triggering toevoegen", + "addPortTriggering": "Poortbereik triggering toevoegen", "addedWidgetNamed": "{name} toegevoegd", "adding": "Toevoegen…", "additionalFiltersOnlineOnly": "Aanvullende filters zijn alleen beschikbaar voor online apparaten.", @@ -434,6 +439,17 @@ "changeNetworkSettingsTitle": "Netwerkinstellingen wijzigen?", "changeRouterPassword": "Routerwachtwoord wijzigen", "channelNumber": "Kanaalnummer", + "channelAutoRecommended": "Auto (recommended)", + "channelCurrentlyUsing": "Currently using: {channel}", + "@channelCurrentlyUsing": { + "placeholders": { + "channel": { + "type": "int" + } + } + }, + "channelDfsSuffix": "· DFS", + "channelNoManualOptions": "No manual channels available for this band.", "channels": "Kanalen", "checkConnectivityAndSpeed": "Connectiviteit en snelheid controleren", "checkForUpdates": "Controleren op updates", @@ -462,6 +478,8 @@ "confirmationRequired": "Bevestiging vereist", "confirmingNewFirmware": "Bevestigen dat de nieuwe firmware draait…", "connect": "Verbinden", + "duplicateMacAddress": "Dit MAC-adres is al gereserveerd", + "duplicateIpAddress": "Dit IP-adres is al gereserveerd", "connectedDevices": "Verbonden apparaten", "connecting": "Verbinden...", "connectingToRouter": "Verbinden met router...", @@ -546,6 +564,9 @@ "diagnosticsRecWeakWifiTitle": "Zwak WiFi-signaal", "disable": "Uitschakelen", "disableInstantPrivacyDesc": "Alle apparaten kunnen vrij verbinding maken met uw netwerk.", + "privateMacWarningTitle": "Sommige apparaten gebruiken een privé-wifi-adres", + "privateMacWarningDesc": "De onderstaande apparaten gebruiken een privé (willekeurig) wifi-adres. Omdat dit adres na verloop van tijd verandert, kunnen ze na een wisseling worden geblokkeerd — ook het apparaat dat u nu gebruikt. Schakel voor deze apparaten \"Privé-wifi-adres\" uit voordat u Instant Privacy inschakelt om verbonden te blijven.", + "privateMacLabel": "Privé", "disableInstantPrivacyTitle": "Instant Privacy uitschakelen?", "discards": "Verworpen", "distribution": "Verdeling", @@ -572,7 +593,7 @@ "dynamicFrequencySelection": "Dynamic Frequency Selection (DFS)", "editPortForwarding": "Port forwarding bewerken", "editPortRangeForwarding": "Poortbereik forwarding bewerken", - "editPortTriggering": "Port triggering bewerken", + "editPortTriggering": "Poortbereik triggering bewerken", "editRule": "Regel bewerken", "editStaticRoute": "Statische route bewerken", "editTimeSettings": "Tijdinstellingen bewerken", @@ -596,7 +617,6 @@ "errorInvalidCredentials": "Onjuiste gebruikersnaam of wachtwoord.", "errorInvalidInput": "De ingevoerde waarde is niet geldig. Controleer deze en probeer het opnieuw.", "errorInvalidSessionToken": "Uw sessie is ongeldig. Meld u opnieuw aan.", - "errorLoadingSpeedTest": "Fout bij het laden van de snelheidstest", "errorNetwork": "Netwerkfout. Controleer uw verbinding en probeer het opnieuw.", "errorNotAuthenticated": "U bent niet aangemeld. Meld u aan en probeer het opnieuw.", "errorResourceNotFound": "De gevraagde instelling kon niet op de router worden gevonden.", @@ -763,6 +783,9 @@ "noAdvancedWifiSettings": "Geen geavanceerde WiFi-instellingen beschikbaar voor dit apparaat.", "noAnswersReturned": "Geen antwoorden ontvangen.", "noAppsInstalled": "Geen apps geïnstalleerd op deze router", + "unableToLoadApps": "Kan apps niet laden", + "unableToLoadHealthData": "Kan statusgegevens niet laden", + "unableToLoadTopology": "Kan topologie niet laden", "noClients": "Geen clients", "noDeviceActivityRecorded": "Geen apparaatactiviteit geregistreerd", "noDevicesCurrentlyConnected": "Er zijn momenteel geen apparaten verbonden.", @@ -780,7 +803,7 @@ "noLogFilesAvailable": "Geen logbestanden beschikbaar op deze router", "noPortMappingsConfigured": "Geen poorttoewijzingen geconfigureerd", "noPortRangeRules": "Geen poortbereik forwarding-regels geconfigureerd", - "noPortTriggeringRules": "Geen port triggering-regels geconfigureerd", + "noPortTriggeringRules": "Geen poortbereik triggering regels geconfigureerd", "noPresetSelected": "Geen voorinstelling geselecteerd", "noReservationIpMayChange": "Geen reservering. IP kan veranderen bij opnieuw verbinden.", "noSinglePortRules": "Geen enkele-poort forwarding-regels geconfigureerd", @@ -836,9 +859,8 @@ "portMapping": "Poorttoewijzing", "portMappingSubtitle": "Port forwarding-regels en DMZ-configuratie", "portMustBe1To65535": "Poort moet tussen 1-65535 liggen", - "portRangeWithCount": "Poortbereik ({count})", "portRules": "Poortregels", - "portTriggering": "Port triggering", + "portTriggering": "Poortbereik triggering", "ports": "Poorten", "potentialIssues": "Mogelijke problemen", "pppStatus": "PPP-status", @@ -882,6 +904,12 @@ "renew": "Vernieuwen", "requiredLabel": "(Vereist)", "reservationReleased": "Reservering vrijgegeven", + "reservationAdded": "Reservering toegevoegd", + "reservationDeleted": "Reservering verwijderd", + "reconnectedToRouter": "Opnieuw verbonden met router", + "timeSettingsSaved": "Tijdinstellingen opgeslagen", + "ruleAdded": "Regel toegevoegd", + "channelUpdated": "Kanaal bijgewerkt", "reservations": "Reserveringen", "reserveIpAddress": "IP-adres reserveren", "reserved": "Gereserveerd", @@ -908,6 +936,8 @@ "routerRebootComplete": "Opnieuw opstarten router voltooid", "routerWritingImage": "De router schrijft de nieuwe image. Schakel het apparaat niet uit.", "rtColumn": "RT", + "unableToLoadDiagnostics": "Kan diagnostiek niet laden", + "unableToLoadSpeedTest": "Kan snelheidstest niet laden", "rules": "Regels", "runAgain": "Opnieuw uitvoeren", "runDiagnosticsTitle": "Diagnostiek uitvoeren", @@ -942,7 +972,6 @@ "signalQuality": "Signaalkwaliteit", "signalQualitySubtitle": "WiFi-signaalkwaliteit per frequentieband", "signalStrengthDbm": "{value} dBm", - "singlePortWithCount": "Enkele poort ({count})", "singleRouterSetupNoBackhaul": "Installatie met één router — geen backhaul", "sizeBytes": "Grootte: {size} bytes ({mib} MiB)", "skipped": "Overgeslagen", @@ -1003,15 +1032,10 @@ "trend": "Trend", "trends": "Trends", "triggerPorts": "Triggerpoorten", - "triggeringWithCount": "Triggering ({count})", "txPower": "Tx-vermogen", "type": "Type", "typeAMessage": "Typ een bericht...", "unableToGatherDeviceInfo": "Kan apparaatinformatie niet verzamelen", - "unableToLoadApps": "Kan apps niet laden", - "unableToLoadDiagnostics": "Kan diagnostiek niet laden", - "unableToLoadSpeedTest": "Kan snelheidstest niet laden", - "unableToLoadTopology": "Kan topologie niet laden", "unchangedLabel": "(Ongewijzigd)", "unknownWidget": "Onbekende widget: {id}", "unnamed": "(naamloos)", @@ -1099,5 +1123,12 @@ "targetChain": "Doelketen", "synchronized": "Gesynchroniseerd", "noPortForwardingRulesConfigured": "Geen regels voor poortdoorschakeling geconfigureerd", - "nItems": "{count, plural, one{{count} item} other{{count} items}}" + "nItems": "{count, plural, one{{count} item} other{{count} items}}", + "bridgeReconnectHint": "Maak na het opslaan opnieuw verbinding met uw router via {url}.", + "bridgeRedirectTitle": "Opnieuw verbinden met uw router", + "bridgeRedirectMessage": "Uw router is nu een transparante bridge en wijst geen lokale IP-adressen meer toe. Maak opnieuw verbinding via {url}.", + "bridgeRedirectButton": "Naar router", + "lanIpRedirectTitle": "Opnieuw verbinden met uw router", + "lanIpRedirectMessage": "Het IP-adres van uw router is gewijzigd. Maak opnieuw verbinding via {url}. Mogelijk moet u even wachten totdat uw apparaat een nieuw adres heeft ontvangen.", + "lanIpRedirectButton": "Naar router" } diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index daf07e099..63d409257 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -126,6 +126,8 @@ "invalidDestinationMacAddress": "Nieprawidłowy docelowy adres MAC", "invalidDns": "Nieprawidłowy DNS", "invalidGatewayIpAddress": "Nieprawidłowy adres IP bramy", + "gatewayMustBeWithinLanSubnet": "Brama musi znajdować się w podsieci LAN", + "gatewayMustBeOutsideLanSubnet": "Brama musi znajdować się poza podsiecią LAN", "invalidInput": "Nieprawidłowe dane wejściowe", "invalidIpAddress": "Nieprawidłowy adres IP", "invalidMACAddress": "Nieprawidłowy adres MAC.", @@ -134,6 +136,7 @@ "ipv4": "IPv4", "ipv6": "IPv6", "ipv6Address": "Adres IPv6", + "ipv6ScopeLinkLocal": "Link-local", "keepAlive": "Utrzymywanie aktywności", "l2tpPassthrough": "L2TP Passthrough", "labelInterface": "Interfejs", @@ -237,9 +240,11 @@ "prefix": "Prefiks", "prefixLength": "Długość prefiksu", "print": "Drukuj", + "privateMac": "Prywatny", "privacyAndSecurity": "Prywatność i bezpieczeństwo", "processing": "Przetwarzanie...", "protocol": "Protokół", + "publicMac": "Publiczny", "quickSetup": "Szybka konfiguracja", "applyToAllBandsDesc": "Zastosuj te same ustawienia WiFi do wszystkich pasm jednocześnie", "rebootChildTitle": "Uruchom ponownie węzeł i wszystkie jego węzły podrzędne", @@ -378,7 +383,7 @@ "addDeviceManually": "Dodaj urządzenie ręcznie", "addPortForwarding": "Dodaj przekierowanie portów", "addPortRangeForwarding": "Dodaj przekierowanie zakresu portów", - "addPortTriggering": "Dodaj wyzwalanie portów", + "addPortTriggering": "Dodaj wyzwalanie zakresu portów", "addedWidgetNamed": "Dodano {name}", "adding": "Dodawanie…", "additionalFiltersOnlineOnly": "Dodatkowe filtry są dostępne tylko dla urządzeń online.", @@ -432,6 +437,17 @@ "changeNetworkSettingsTitle": "Zmienić ustawienia sieci?", "changeRouterPassword": "Zmień hasło routera", "channelNumber": "Numer kanału", + "channelAutoRecommended": "Auto (recommended)", + "channelCurrentlyUsing": "Currently using: {channel}", + "@channelCurrentlyUsing": { + "placeholders": { + "channel": { + "type": "int" + } + } + }, + "channelDfsSuffix": "· DFS", + "channelNoManualOptions": "No manual channels available for this band.", "channels": "Kanały", "checkConnectivityAndSpeed": "Sprawdź łączność i szybkość", "checkForUpdates": "Sprawdź aktualizacje", @@ -460,6 +476,8 @@ "confirmationRequired": "Wymagane potwierdzenie", "confirmingNewFirmware": "Potwierdzanie działania nowego oprogramowania układowego…", "connect": "Połącz", + "duplicateMacAddress": "Ten adres MAC jest już zarezerwowany", + "duplicateIpAddress": "Ten adres IP jest już zarezerwowany", "connectedDevices": "Podłączone urządzenia", "connecting": "Łączenie...", "connectingToRouter": "Łączenie z routerem...", @@ -544,6 +562,9 @@ "diagnosticsRecWeakWifiTitle": "Słaby sygnał WiFi", "disable": "Wyłącz", "disableInstantPrivacyDesc": "Wszystkie urządzenia będą mogły swobodnie łączyć się z Twoją siecią.", + "privateMacWarningTitle": "Niektóre urządzenia używają prywatnego adresu Wi-Fi", + "privateMacWarningDesc": "Wymienione poniżej urządzenia używają prywatnego (losowego) adresu Wi-Fi. Ponieważ ten adres zmienia się z czasem, po jego zmianie mogą zostać zablokowane — nawet to, którego używasz teraz. Aby zachować połączenie, wyłącz opcję „Prywatny adres Wi-Fi” dla tych urządzeń przed włączeniem Instant Privacy.", + "privateMacLabel": "Prywatny", "disableInstantPrivacyTitle": "Wyłączyć Instant Privacy?", "discards": "Odrzucenia", "distribution": "Rozkład", @@ -570,7 +591,7 @@ "dynamicFrequencySelection": "Dynamiczny wybór częstotliwości (DFS)", "editPortForwarding": "Edytuj przekierowanie portów", "editPortRangeForwarding": "Edytuj przekierowanie zakresu portów", - "editPortTriggering": "Edytuj wyzwalanie portów", + "editPortTriggering": "Edytuj wyzwalanie zakresu portów", "editRule": "Edytuj regułę", "editStaticRoute": "Edytuj trasę statyczną", "editTimeSettings": "Edytuj ustawienia czasu", @@ -594,7 +615,6 @@ "errorInvalidCredentials": "Nieprawidłowa nazwa użytkownika lub hasło.", "errorInvalidInput": "Wprowadzona wartość jest nieprawidłowa. Sprawdź ją i spróbuj ponownie.", "errorInvalidSessionToken": "Twoja sesja jest nieprawidłowa. Zaloguj się ponownie.", - "errorLoadingSpeedTest": "Błąd ładowania testu szybkości", "errorNetwork": "Błąd sieci. Sprawdź połączenie i spróbuj ponownie.", "errorNotAuthenticated": "Nie jesteś zalogowany. Zaloguj się i spróbuj ponownie.", "errorResourceNotFound": "Nie można znaleźć żądanego ustawienia na routerze.", @@ -763,6 +783,9 @@ "noAdvancedWifiSettings": "Brak zaawansowanych ustawień WiFi dla tego urządzenia.", "noAnswersReturned": "Nie zwrócono odpowiedzi.", "noAppsInstalled": "Na tym routerze nie zainstalowano żadnych aplikacji", + "unableToLoadApps": "Nie można załadować aplikacji", + "unableToLoadHealthData": "Nie można załadować danych o stanie", + "unableToLoadTopology": "Nie można załadować topologii", "noClients": "Brak klientów", "noDeviceActivityRecorded": "Nie zarejestrowano aktywności urządzeń", "noDevicesCurrentlyConnected": "Obecnie nie ma podłączonych urządzeń.", @@ -780,7 +803,7 @@ "noLogFilesAvailable": "Na tym routerze nie ma żadnych plików dziennika", "noPortMappingsConfigured": "Nie skonfigurowano mapowań portów", "noPortRangeRules": "Nie skonfigurowano reguł przekierowania zakresu portów", - "noPortTriggeringRules": "Nie skonfigurowano reguł wyzwalania portów", + "noPortTriggeringRules": "Brak skonfigurowanych reguł wyzwalania zakresu portów", "noPresetSelected": "Nie wybrano ustawienia wstępnego", "noReservationIpMayChange": "Brak rezerwacji. Adres IP może się zmienić po ponownym połączeniu.", "noSinglePortRules": "Nie skonfigurowano reguł przekierowania pojedynczego portu", @@ -836,9 +859,8 @@ "portMapping": "Mapowanie portów", "portMappingSubtitle": "Reguły przekierowania portów i konfiguracja DMZ", "portMustBe1To65535": "Port musi mieścić się w zakresie 1-65535", - "portRangeWithCount": "Zakres portów ({count})", "portRules": "Reguły portów", - "portTriggering": "Wyzwalanie portów", + "portTriggering": "Wyzwalanie zakresu portów", "ports": "Porty", "potentialIssues": "Potencjalne problemy", "pppStatus": "Stan PPP", @@ -882,6 +904,12 @@ "renew": "Odnów", "requiredLabel": "(Wymagane)", "reservationReleased": "Zwolniono rezerwację", + "reservationAdded": "Dodano rezerwację", + "reservationDeleted": "Usunięto rezerwację", + "reconnectedToRouter": "Ponownie połączono z routerem", + "timeSettingsSaved": "Zapisano ustawienia czasu", + "ruleAdded": "Dodano regułę", + "channelUpdated": "Zaktualizowano kanał", "reservations": "Rezerwacje", "reserveIpAddress": "Zarezerwuj adres IP", "reserved": "Zarezerwowane", @@ -908,6 +936,8 @@ "routerRebootComplete": "Ponowne uruchamianie routera zakończone", "routerWritingImage": "Router zapisuje nowy obraz. Nie wyłączaj zasilania.", "rtColumn": "RT", + "unableToLoadDiagnostics": "Nie można załadować diagnostyki", + "unableToLoadSpeedTest": "Nie można załadować testu szybkości", "rules": "Reguły", "runAgain": "Uruchom ponownie", "runDiagnosticsTitle": "Uruchom diagnostykę", @@ -942,7 +972,6 @@ "signalQuality": "Jakość sygnału", "signalQualitySubtitle": "Jakość sygnału WiFi według pasma częstotliwości", "signalStrengthDbm": "{value} dBm", - "singlePortWithCount": "Pojedynczy port ({count})", "singleRouterSetupNoBackhaul": "Konfiguracja z jednym routerem — brak backhaul", "sizeBytes": "Rozmiar: {size} bajtów ({mib} MiB)", "skipped": "Pominięto", @@ -1003,15 +1032,10 @@ "trend": "Trend", "trends": "Trendy", "triggerPorts": "Porty wyzwalające", - "triggeringWithCount": "Wyzwalanie ({count})", "txPower": "Moc nadawania", "type": "Typ", "typeAMessage": "Wpisz wiadomość...", "unableToGatherDeviceInfo": "Nie można zebrać informacji o urządzeniu", - "unableToLoadApps": "Nie można załadować aplikacji", - "unableToLoadDiagnostics": "Nie można załadować diagnostyki", - "unableToLoadSpeedTest": "Nie można załadować testu szybkości", - "unableToLoadTopology": "Nie można załadować topologii", "unchangedLabel": "(Bez zmian)", "unknownWidget": "Nieznany widżet: {id}", "unnamed": "(bez nazwy)", @@ -1099,5 +1123,12 @@ "targetChain": "Łańcuch docelowy", "synchronized": "Zsynchronizowano", "noPortForwardingRulesConfigured": "Nie skonfigurowano żadnych reguł przekierowania portów", - "nItems": "{count, plural, one{{count} element} few{{count} elementy} many{{count} elementów} other{{count} elementu}}" + "nItems": "{count, plural, one{{count} element} few{{count} elementy} many{{count} elementów} other{{count} elementu}}", + "bridgeReconnectHint": "Po zapisaniu połącz się ponownie z routerem pod adresem {url}.", + "bridgeRedirectTitle": "Połącz się ponownie z routerem", + "bridgeRedirectMessage": "Twój router działa teraz jako most przezroczysty i nie przydziela już lokalnych adresów IP. Połącz się ponownie pod adresem {url}.", + "bridgeRedirectButton": "Przejdź do routera", + "lanIpRedirectTitle": "Połącz się ponownie z routerem", + "lanIpRedirectMessage": "Adres IP routera uległ zmianie. Połącz się z nim ponownie pod adresem {url}. Może być konieczne odczekanie chwili, aż urządzenie uzyska nowy adres.", + "lanIpRedirectButton": "Przejdź do routera" } diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 4f062757c..f8d04937e 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -126,6 +126,8 @@ "invalidDestinationMacAddress": "Endereço MAC de destino inválido", "invalidDns": "DNS inválido", "invalidGatewayIpAddress": "Endereço IP de gateway inválido", + "gatewayMustBeWithinLanSubnet": "O gateway deve estar dentro da sub-rede LAN", + "gatewayMustBeOutsideLanSubnet": "O gateway deve estar fora da sub-rede LAN", "invalidInput": "Entrada inválida", "invalidIpAddress": "Endereço IP inválido", "invalidMACAddress": "Endereço MAC inválido.", @@ -134,6 +136,7 @@ "ipv4": "IPv4", "ipv6": "IPv6", "ipv6Address": "Endereço IPv6", + "ipv6ScopeLinkLocal": "Link-local", "keepAlive": "Funcionamento", "l2tpPassthrough": "Passagem L2TP", "labelInterface": "Interface", @@ -239,9 +242,11 @@ "prefix": "Prefixo", "prefixLength": "Comprimento do prefixo", "print": "Imprimir", + "privateMac": "Privado", "privacyAndSecurity": "Privacidade e Segurança", "processing": "Processando...", "protocol": "Protocolo", + "publicMac": "Público", "quickSetup": "Configuração rápida", "applyToAllBandsDesc": "Aplicar as mesmas configurações de WiFi a todas as bandas de uma só vez", "rebootChildTitle": "Reiniciar o nó e todos os seus nós filhos", @@ -380,7 +385,7 @@ "addDeviceManually": "Adicionar dispositivo manualmente", "addPortForwarding": "Adicionar encaminhamento de portas", "addPortRangeForwarding": "Adicionar encaminhamento de intervalo de portas", - "addPortTriggering": "Adicionar acionamento de portas", + "addPortTriggering": "Adicionar acionamento de intervalo de portas", "addedWidgetNamed": "{name} adicionado", "adding": "Adicionando…", "additionalFiltersOnlineOnly": "Filtros adicionais estão disponíveis apenas para dispositivos online.", @@ -434,6 +439,17 @@ "changeNetworkSettingsTitle": "Alterar configurações de rede?", "changeRouterPassword": "Alterar senha do roteador", "channelNumber": "Número do canal", + "channelAutoRecommended": "Auto (recommended)", + "channelCurrentlyUsing": "Currently using: {channel}", + "@channelCurrentlyUsing": { + "placeholders": { + "channel": { + "type": "int" + } + } + }, + "channelDfsSuffix": "· DFS", + "channelNoManualOptions": "No manual channels available for this band.", "channels": "Canais", "checkConnectivityAndSpeed": "Verificar conectividade e velocidade", "checkForUpdates": "Verificar atualizações", @@ -462,6 +478,8 @@ "confirmationRequired": "Confirmação necessária", "confirmingNewFirmware": "Confirmando se o novo firmware está em execução…", "connect": "Conectar", + "duplicateMacAddress": "Este endereço MAC já está reservado", + "duplicateIpAddress": "Este endereço IP já está reservado", "connectedDevices": "Dispositivos conectados", "connecting": "Conectando...", "connectingToRouter": "Conectando ao roteador...", @@ -546,6 +564,9 @@ "diagnosticsRecWeakWifiTitle": "Sinal de WiFi fraco", "disable": "Desabilitar", "disableInstantPrivacyDesc": "Todos os dispositivos poderão se conectar livremente à sua rede.", + "privateMacWarningTitle": "Alguns dispositivos usam um endereço Wi-Fi privado", + "privateMacWarningDesc": "Os dispositivos listados abaixo usam um endereço Wi-Fi privado (aleatório). Como esse endereço muda com o tempo, eles podem ser bloqueados após a rotação — inclusive o que você está usando agora. Para continuar conectado, desative o \"Endereço Wi-Fi privado\" nesses dispositivos antes de ativar o Instant Privacy.", + "privateMacLabel": "Privado", "disableInstantPrivacyTitle": "Desativar o Instant Privacy?", "discards": "Descartes", "distribution": "Distribuição", @@ -572,7 +593,7 @@ "dynamicFrequencySelection": "Seleção Dinâmica de Frequência (DFS)", "editPortForwarding": "Editar encaminhamento de portas", "editPortRangeForwarding": "Editar encaminhamento de intervalo de portas", - "editPortTriggering": "Editar acionamento de portas", + "editPortTriggering": "Editar acionamento de intervalo de portas", "editRule": "Editar regra", "editStaticRoute": "Editar rota estática", "editTimeSettings": "Editar configurações de hora", @@ -596,7 +617,6 @@ "errorInvalidCredentials": "Nome de usuário ou senha incorretos.", "errorInvalidInput": "O valor inserido não é válido. Verifique e tente novamente.", "errorInvalidSessionToken": "Sua sessão é inválida. Faça login novamente.", - "errorLoadingSpeedTest": "Erro ao carregar o teste de velocidade", "errorNetwork": "Erro de rede. Verifique sua conexão e tente novamente.", "errorNotAuthenticated": "Você não está conectado. Faça login e tente novamente.", "errorResourceNotFound": "Não foi possível encontrar a configuração solicitada no roteador.", @@ -763,6 +783,9 @@ "noAdvancedWifiSettings": "Não há configurações avançadas de WiFi disponíveis para este dispositivo.", "noAnswersReturned": "Nenhuma resposta retornada.", "noAppsInstalled": "Nenhum app instalado neste roteador", + "unableToLoadApps": "Não foi possível carregar os apps", + "unableToLoadHealthData": "Não foi possível carregar os dados de estado", + "unableToLoadTopology": "Não foi possível carregar a topologia", "noClients": "Nenhum cliente", "noDeviceActivityRecorded": "Nenhuma atividade de dispositivo registrada", "noDevicesCurrentlyConnected": "Nenhum dispositivo conectado no momento.", @@ -780,7 +803,7 @@ "noLogFilesAvailable": "Nenhum arquivo de log disponível neste roteador", "noPortMappingsConfigured": "Nenhum mapeamento de portas configurado", "noPortRangeRules": "Nenhuma regra de encaminhamento de intervalo de portas configurada", - "noPortTriggeringRules": "Nenhuma regra de acionamento de portas configurada", + "noPortTriggeringRules": "Nenhuma regra de acionamento de intervalo de portas configurada", "noPresetSelected": "Nenhuma predefinição selecionada", "noReservationIpMayChange": "Sem reserva. O IP pode mudar na reconexão.", "noSinglePortRules": "Nenhuma regra de encaminhamento de porta única configurada", @@ -836,9 +859,8 @@ "portMapping": "Mapeamento de portas", "portMappingSubtitle": "Regras de encaminhamento de portas e configuração de DMZ", "portMustBe1To65535": "A porta deve estar entre 1 e 65535", - "portRangeWithCount": "Intervalo de portas ({count})", "portRules": "Regras de portas", - "portTriggering": "Acionamento de portas", + "portTriggering": "Acionamento de intervalo de portas", "ports": "Portas", "potentialIssues": "Possíveis problemas", "pppStatus": "Status do PPP", @@ -882,6 +904,12 @@ "renew": "Renovar", "requiredLabel": "(Obrigatório)", "reservationReleased": "Reserva liberada", + "reservationAdded": "Reserva adicionada", + "reservationDeleted": "Reserva excluída", + "reconnectedToRouter": "Reconectado ao roteador", + "timeSettingsSaved": "Configurações de hora salvas", + "ruleAdded": "Regra adicionada", + "channelUpdated": "Canal atualizado", "reservations": "Reservas", "reserveIpAddress": "Reservar endereço IP", "reserved": "Reservado", @@ -908,6 +936,8 @@ "routerRebootComplete": "Reinicialização do roteador concluída", "routerWritingImage": "O roteador está gravando a nova imagem. Não desligue.", "rtColumn": "RT", + "unableToLoadDiagnostics": "Não foi possível carregar o diagnóstico", + "unableToLoadSpeedTest": "Não foi possível carregar o teste de velocidade", "rules": "Regras", "runAgain": "Executar novamente", "runDiagnosticsTitle": "Executar diagnóstico", @@ -942,7 +972,6 @@ "signalQuality": "Qualidade do sinal", "signalQualitySubtitle": "Qualidade do sinal WiFi por banda de frequência", "signalStrengthDbm": "{value} dBm", - "singlePortWithCount": "Porta única ({count})", "singleRouterSetupNoBackhaul": "Configuração de roteador único — sem backhaul", "sizeBytes": "Tamanho: {size} bytes ({mib} MiB)", "skipped": "Ignorado", @@ -1003,15 +1032,10 @@ "trend": "Tendência", "trends": "Tendências", "triggerPorts": "Portas de acionamento", - "triggeringWithCount": "Acionamento ({count})", "txPower": "Potência de Tx", "type": "Tipo", "typeAMessage": "Digite uma mensagem...", "unableToGatherDeviceInfo": "Não foi possível reunir as informações do dispositivo", - "unableToLoadApps": "Não foi possível carregar os apps", - "unableToLoadDiagnostics": "Não foi possível carregar o diagnóstico", - "unableToLoadSpeedTest": "Não foi possível carregar o teste de velocidade", - "unableToLoadTopology": "Não foi possível carregar a topologia", "unchangedLabel": "(Inalterado)", "unknownWidget": "Widget desconhecido: {id}", "unnamed": "(sem nome)", @@ -1099,5 +1123,12 @@ "targetChain": "Cadeia de destino", "synchronized": "Sincronizado", "noPortForwardingRulesConfigured": "Nenhuma regra de encaminhamento de portas configurada", - "nItems": "{count, plural, one{{count} item} other{{count} itens}}" + "nItems": "{count, plural, one{{count} item} other{{count} itens}}", + "bridgeReconnectHint": "Após salvar, reconecte-se ao seu roteador em {url}.", + "bridgeRedirectTitle": "Reconectar ao seu roteador", + "bridgeRedirectMessage": "Seu roteador agora é uma ponte transparente e não distribui mais endereços IP locais. Reconecte-se em {url}.", + "bridgeRedirectButton": "Ir para o roteador", + "lanIpRedirectTitle": "Reconectar ao seu roteador", + "lanIpRedirectMessage": "O endereço IP do seu roteador foi alterado. Reconecte-se a ele em {url}. Talvez seja necessário aguardar um momento até que seu dispositivo obtenha um novo endereço.", + "lanIpRedirectButton": "Ir para o roteador" } diff --git a/lib/l10n/app_pt_pt.arb b/lib/l10n/app_pt_pt.arb index 1d637511e..7b52cf0a8 100644 --- a/lib/l10n/app_pt_pt.arb +++ b/lib/l10n/app_pt_pt.arb @@ -126,6 +126,8 @@ "invalidDestinationMacAddress": "Endereço MAC de destino inválido", "invalidDns": "DNS inválido", "invalidGatewayIpAddress": "Endereço IP de gateway inválido", + "gatewayMustBeWithinLanSubnet": "O gateway deve estar dentro da sub-rede LAN", + "gatewayMustBeOutsideLanSubnet": "O gateway deve estar fora da sub-rede LAN", "invalidInput": "Entrada inválida", "invalidIpAddress": "Endereço IP inválido", "invalidMACAddress": "Endereço MAC inválido.", @@ -134,6 +136,7 @@ "ipv4": "IPv4", "ipv6": "IPv6", "ipv6Address": "Endereço IPv6", + "ipv6ScopeLinkLocal": "Link-local", "keepAlive": "Manter ligado", "l2tpPassthrough": "Passagem L2TP", "labelInterface": "Interface", @@ -239,9 +242,11 @@ "prefix": "Prefixo", "prefixLength": "Comprimento do prefixo", "print": "Imprimir", + "privateMac": "Privado", "privacyAndSecurity": "Privacidade e Segurança", "processing": "Processando...", "protocol": "Protocolo", + "publicMac": "Público", "quickSetup": "Configuração rápida", "applyToAllBandsDesc": "Aplicar as mesmas configurações de WiFi a todas as bandas de uma só vez", "rebootChildTitle": "Reiniciar o nó e todos os seus nós filhos", @@ -380,7 +385,7 @@ "addDeviceManually": "Adicionar dispositivo manualmente", "addPortForwarding": "Adicionar reencaminhamento de portas", "addPortRangeForwarding": "Adicionar reencaminhamento de intervalo de portas", - "addPortTriggering": "Adicionar acionamento de portas", + "addPortTriggering": "Adicionar acionamento de intervalo de portas", "addedWidgetNamed": "{name} adicionado", "adding": "A adicionar…", "additionalFiltersOnlineOnly": "Os filtros adicionais só estão disponíveis para dispositivos online.", @@ -434,6 +439,17 @@ "changeNetworkSettingsTitle": "Alterar definições de rede?", "changeRouterPassword": "Alterar palavra-passe do router", "channelNumber": "Número do canal", + "channelAutoRecommended": "Auto (recommended)", + "channelCurrentlyUsing": "Currently using: {channel}", + "@channelCurrentlyUsing": { + "placeholders": { + "channel": { + "type": "int" + } + } + }, + "channelDfsSuffix": "· DFS", + "channelNoManualOptions": "No manual channels available for this band.", "channels": "Canais", "checkConnectivityAndSpeed": "Verificar conectividade e velocidade", "checkForUpdates": "Procurar atualizações", @@ -462,6 +478,8 @@ "confirmationRequired": "Confirmação necessária", "confirmingNewFirmware": "A confirmar que o novo firmware está em execução…", "connect": "Ligar", + "duplicateMacAddress": "Este endereço MAC já está reservado", + "duplicateIpAddress": "Este endereço IP já está reservado", "connectedDevices": "Dispositivos ligados", "connecting": "A ligar...", "connectingToRouter": "A ligar ao router...", @@ -546,6 +564,9 @@ "diagnosticsRecWeakWifiTitle": "Sinal WiFi fraco", "disable": "Desativar", "disableInstantPrivacyDesc": "Todos os dispositivos poderão ligar-se livremente à sua rede.", + "privateMacWarningTitle": "Alguns dispositivos utilizam um endereço Wi-Fi privado", + "privateMacWarningDesc": "Os dispositivos indicados abaixo utilizam um endereço Wi-Fi privado (aleatório). Uma vez que este endereço muda ao longo do tempo, podem ser bloqueados após a rotação — incluindo aquele que está a utilizar agora. Para se manter ligado, desative o \"Endereço Wi-Fi privado\" nestes dispositivos antes de ativar a Privacidade Instantânea.", + "privateMacLabel": "Privado", "disableInstantPrivacyTitle": "Desativar Privacidade Instantânea?", "discards": "Descartes", "distribution": "Distribuição", @@ -572,7 +593,7 @@ "dynamicFrequencySelection": "Seleção Dinâmica de Frequência (DFS)", "editPortForwarding": "Editar reencaminhamento de portas", "editPortRangeForwarding": "Editar reencaminhamento de intervalo de portas", - "editPortTriggering": "Editar acionamento de portas", + "editPortTriggering": "Editar acionamento de intervalo de portas", "editRule": "Editar regra", "editStaticRoute": "Editar percurso estático", "editTimeSettings": "Editar definições de hora", @@ -596,7 +617,6 @@ "errorInvalidCredentials": "Nome de utilizador ou palavra-passe incorretos.", "errorInvalidInput": "O valor introduzido não é válido. Verifique e tente novamente.", "errorInvalidSessionToken": "A sua sessão é inválida. Inicie sessão novamente.", - "errorLoadingSpeedTest": "Erro ao carregar o teste de velocidade", "errorNetwork": "Erro de rede. Verifique a sua ligação e tente novamente.", "errorNotAuthenticated": "Não tem sessão iniciada. Inicie sessão e tente novamente.", "errorResourceNotFound": "Não foi possível encontrar a definição pretendida no router.", @@ -763,6 +783,9 @@ "noAdvancedWifiSettings": "Não há definições avançadas de WiFi disponíveis para este dispositivo.", "noAnswersReturned": "Não foram devolvidas respostas.", "noAppsInstalled": "Não há aplicações instaladas neste router", + "unableToLoadApps": "Não foi possível carregar as aplicações", + "unableToLoadHealthData": "Não foi possível carregar os dados de estado", + "unableToLoadTopology": "Não foi possível carregar a topologia", "noClients": "Sem clientes", "noDeviceActivityRecorded": "Não foi registada atividade do dispositivo", "noDevicesCurrentlyConnected": "Não há dispositivos ligados de momento.", @@ -780,7 +803,7 @@ "noLogFilesAvailable": "Não há ficheiros de registo disponíveis neste router", "noPortMappingsConfigured": "Não há mapeamentos de portas configurados", "noPortRangeRules": "Não há regras de reencaminhamento de intervalo de portas configuradas", - "noPortTriggeringRules": "Não há regras de acionamento de portas configuradas", + "noPortTriggeringRules": "Nenhuma regra de acionamento de intervalo de portas configurada", "noPresetSelected": "Nenhuma predefinição selecionada", "noReservationIpMayChange": "Sem reserva. O IP pode mudar ao reconectar.", "noSinglePortRules": "Não há regras de reencaminhamento de porta única configuradas", @@ -836,9 +859,8 @@ "portMapping": "Mapeamento de portas", "portMappingSubtitle": "Regras de reencaminhamento de portas e configuração de DMZ", "portMustBe1To65535": "A porta deve estar entre 1 e 65535", - "portRangeWithCount": "Intervalo de portas ({count})", "portRules": "Regras de portas", - "portTriggering": "Acionamento de portas", + "portTriggering": "Acionamento de intervalo de portas", "ports": "Portas", "potentialIssues": "Potenciais problemas", "pppStatus": "Estado PPP", @@ -882,6 +904,12 @@ "renew": "Renovar", "requiredLabel": "(Obrigatório)", "reservationReleased": "Reserva libertada", + "reservationAdded": "Reserva adicionada", + "reservationDeleted": "Reserva eliminada", + "reconnectedToRouter": "Reconectado ao router", + "timeSettingsSaved": "Definições de hora guardadas", + "ruleAdded": "Regra adicionada", + "channelUpdated": "Canal atualizado", "reservations": "Reservas", "reserveIpAddress": "Reservar endereço IP", "reserved": "Reservado", @@ -908,6 +936,8 @@ "routerRebootComplete": "Reinício do router concluído", "routerWritingImage": "O router está a escrever a nova imagem. Não desligue a alimentação.", "rtColumn": "RT", + "unableToLoadDiagnostics": "Não foi possível carregar o diagnóstico", + "unableToLoadSpeedTest": "Não foi possível carregar o teste de velocidade", "rules": "Regras", "runAgain": "Executar novamente", "runDiagnosticsTitle": "Executar diagnóstico", @@ -942,7 +972,6 @@ "signalQuality": "Qualidade do sinal", "signalQualitySubtitle": "Qualidade do sinal WiFi por banda de frequência", "signalStrengthDbm": "{value} dBm", - "singlePortWithCount": "Porta única ({count})", "singleRouterSetupNoBackhaul": "Configuração de router único — sem backhaul", "sizeBytes": "Tamanho: {size} bytes ({mib} MiB)", "skipped": "Ignorado", @@ -1003,15 +1032,10 @@ "trend": "Tendência", "trends": "Tendências", "triggerPorts": "Portas de acionamento", - "triggeringWithCount": "Acionamento ({count})", "txPower": "Potência Tx", "type": "Tipo", "typeAMessage": "Escreva uma mensagem...", "unableToGatherDeviceInfo": "Não foi possível recolher informações do dispositivo", - "unableToLoadApps": "Não foi possível carregar as aplicações", - "unableToLoadDiagnostics": "Não foi possível carregar o diagnóstico", - "unableToLoadSpeedTest": "Não foi possível carregar o teste de velocidade", - "unableToLoadTopology": "Não foi possível carregar a topologia", "unchangedLabel": "(Inalterado)", "unknownWidget": "Widget desconhecido: {id}", "unnamed": "(sem nome)", @@ -1099,5 +1123,12 @@ "targetChain": "Cadeia de destino", "synchronized": "Sincronizado", "noPortForwardingRulesConfigured": "Nenhuma regra de reencaminhamento de portas configurada", - "nItems": "{count, plural, one{{count} item} other{{count} itens}}" + "nItems": "{count, plural, one{{count} item} other{{count} itens}}", + "bridgeReconnectHint": "Após guardar, volte a ligar-se ao seu router em {url}.", + "bridgeRedirectTitle": "Voltar a ligar ao seu router", + "bridgeRedirectMessage": "O seu router funciona agora como uma ponte transparente e já não atribui endereços IP locais. Volte a ligar-se em {url}.", + "bridgeRedirectButton": "Ir para o router", + "lanIpRedirectTitle": "Voltar a ligar ao seu router", + "lanIpRedirectMessage": "O endereço IP do seu router foi alterado. Volte a ligar-se a ele em {url}. Poderá ter de aguardar um momento até que o seu dispositivo obtenha um novo endereço.", + "lanIpRedirectButton": "Ir para o router" } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 6ed6cce40..ede68abf4 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -126,6 +126,8 @@ "invalidDestinationMacAddress": "Недействительный MAC-адрес назначения", "invalidDns": "Недопустимый DNS-сервер", "invalidGatewayIpAddress": "Недействительный IP-адрес шлюза", + "gatewayMustBeWithinLanSubnet": "Шлюз должен находиться в подсети LAN", + "gatewayMustBeOutsideLanSubnet": "Шлюз должен находиться вне подсети LAN", "invalidInput": "Неверный ввод", "invalidIpAddress": "Недопустимый IP-адрес", "invalidMACAddress": "Недопустимый MAC-адрес.", @@ -134,6 +136,7 @@ "ipv4": "IPv4", "ipv6": "IPv6", "ipv6Address": "IPv6-адрес", + "ipv6ScopeLinkLocal": "Локальный для канала", "keepAlive": "Проверка активности", "l2tpPassthrough": "L2TP-туннель", "labelInterface": "Интерфейс", @@ -237,9 +240,11 @@ "prefix": "Префикс", "prefixLength": "Длина префикса", "print": "Печать", + "privateMac": "Частный", "privacyAndSecurity": "Конфиденциальность и безопасность", "processing": "Обработка...", "protocol": "Протокол", + "publicMac": "Публичный", "quickSetup": "Быстрая настройка", "applyToAllBandsDesc": "Применить одинаковые настройки WiFi ко всем диапазонам сразу", "rebootChildTitle": "Перезагрузить узел и все его дочерние узлы", @@ -378,7 +383,7 @@ "addDeviceManually": "Добавить устройство вручную", "addPortForwarding": "Добавить переадресацию портов", "addPortRangeForwarding": "Добавить переадресацию диапазона портов", - "addPortTriggering": "Добавить триггер портов", + "addPortTriggering": "Добавить триггер диапазона портов", "addedWidgetNamed": "Добавлено: {name}", "adding": "Добавление…", "additionalFiltersOnlineOnly": "Дополнительные фильтры доступны только для устройств в сети.", @@ -432,6 +437,17 @@ "changeNetworkSettingsTitle": "Изменить сетевые настройки?", "changeRouterPassword": "Изменить пароль маршрутизатора", "channelNumber": "Номер канала", + "channelAutoRecommended": "Auto (recommended)", + "channelCurrentlyUsing": "Currently using: {channel}", + "@channelCurrentlyUsing": { + "placeholders": { + "channel": { + "type": "int" + } + } + }, + "channelDfsSuffix": "· DFS", + "channelNoManualOptions": "No manual channels available for this band.", "channels": "Каналы", "checkConnectivityAndSpeed": "Проверить подключение и скорость", "checkForUpdates": "Проверить обновления", @@ -460,6 +476,8 @@ "confirmationRequired": "Требуется подтверждение", "confirmingNewFirmware": "Подтверждение запуска новой прошивки…", "connect": "Подключить", + "duplicateMacAddress": "Этот MAC-адрес уже зарезервирован", + "duplicateIpAddress": "Этот IP-адрес уже зарезервирован", "connectedDevices": "Подключенные устройства", "connecting": "Подключение...", "connectingToRouter": "Подключение к маршрутизатору...", @@ -544,6 +562,9 @@ "diagnosticsRecWeakWifiTitle": "Слабый сигнал WiFi", "disable": "Отключить", "disableInstantPrivacyDesc": "Все устройства смогут свободно подключаться к вашей сети.", + "privateMacWarningTitle": "Некоторые устройства используют частный адрес Wi-Fi", + "privateMacWarningDesc": "Устройства, перечисленные ниже, используют частный (случайный) адрес Wi-Fi. Поскольку этот адрес со временем меняется, после его смены они могут быть заблокированы — даже то, которым вы пользуетесь сейчас. Чтобы не потерять подключение, отключите «Частный адрес Wi-Fi» для этих устройств перед включением Instant Privacy.", + "privateMacLabel": "Частный", "disableInstantPrivacyTitle": "Отключить Instant Privacy?", "discards": "Отброшено", "distribution": "Распределение", @@ -570,7 +591,7 @@ "dynamicFrequencySelection": "Динамический выбор частоты (DFS)", "editPortForwarding": "Редактировать переадресацию портов", "editPortRangeForwarding": "Редактировать переадресацию диапазона портов", - "editPortTriggering": "Редактировать триггер портов", + "editPortTriggering": "Редактировать триггер диапазона портов", "editRule": "Редактировать правило", "editStaticRoute": "Редактировать статический маршрут", "editTimeSettings": "Редактировать настройки времени", @@ -594,7 +615,6 @@ "errorInvalidCredentials": "Неверное имя пользователя или пароль.", "errorInvalidInput": "Введенное значение недействительно. Проверьте и попробуйте снова.", "errorInvalidSessionToken": "Ваш сеанс недействителен. Войдите снова.", - "errorLoadingSpeedTest": "Ошибка загрузки теста скорости", "errorNetwork": "Ошибка сети. Проверьте подключение и попробуйте снова.", "errorNotAuthenticated": "Вы не вошли в систему. Войдите и попробуйте снова.", "errorResourceNotFound": "Запрошенную настройку не удалось найти на маршрутизаторе.", @@ -763,6 +783,9 @@ "noAdvancedWifiSettings": "Расширенные настройки WiFi для этого устройства недоступны.", "noAnswersReturned": "Ответы не получены.", "noAppsInstalled": "На этом маршрутизаторе не установлены приложения", + "unableToLoadApps": "Не удалось загрузить приложения", + "unableToLoadHealthData": "Не удалось загрузить данные о состоянии", + "unableToLoadTopology": "Не удалось загрузить топологию", "noClients": "Нет клиентов", "noDeviceActivityRecorded": "Активность устройств не зафиксирована", "noDevicesCurrentlyConnected": "В настоящее время устройства не подключены.", @@ -780,7 +803,7 @@ "noLogFilesAvailable": "На этом маршрутизаторе нет доступных файлов журнала", "noPortMappingsConfigured": "Сопоставления портов не настроены", "noPortRangeRules": "Правила переадресации диапазона портов не настроены", - "noPortTriggeringRules": "Правила триггера портов не настроены", + "noPortTriggeringRules": "Правила триггера диапазона портов не настроены", "noPresetSelected": "Предустановка не выбрана", "noReservationIpMayChange": "Нет резервирования. IP может измениться при переподключении.", "noSinglePortRules": "Правила переадресации одиночного порта не настроены", @@ -836,9 +859,8 @@ "portMapping": "Сопоставление портов", "portMappingSubtitle": "Правила переадресации портов и конфигурация DMZ", "portMustBe1To65535": "Порт должен быть от 1 до 65535", - "portRangeWithCount": "Диапазон портов ({count})", "portRules": "Правила портов", - "portTriggering": "Триггер портов", + "portTriggering": "Триггер диапазона портов", "ports": "Порты", "potentialIssues": "Возможные проблемы", "pppStatus": "Статус PPP", @@ -882,6 +904,12 @@ "renew": "Обновить", "requiredLabel": "(Обязательно)", "reservationReleased": "Резервирование освобождено", + "reservationAdded": "Резервирование добавлено", + "reservationDeleted": "Резервирование удалено", + "reconnectedToRouter": "Повторное подключение к маршрутизатору выполнено", + "timeSettingsSaved": "Настройки времени сохранены", + "ruleAdded": "Правило добавлено", + "channelUpdated": "Канал обновлен", "reservations": "Резервирования", "reserveIpAddress": "Зарезервировать IP-адрес", "reserved": "Зарезервировано", @@ -908,6 +936,8 @@ "routerRebootComplete": "Перезагрузка маршрутизатора завершена", "routerWritingImage": "Маршрутизатор записывает новый образ. Не выключайте питание.", "rtColumn": "RT", + "unableToLoadDiagnostics": "Не удалось загрузить диагностику", + "unableToLoadSpeedTest": "Не удалось загрузить тест скорости", "rules": "Правила", "runAgain": "Запустить снова", "runDiagnosticsTitle": "Запустить диагностику", @@ -942,7 +972,6 @@ "signalQuality": "Качество сигнала", "signalQualitySubtitle": "Качество сигнала WiFi по частотным диапазонам", "signalStrengthDbm": "{value} dBm", - "singlePortWithCount": "Одиночный порт ({count})", "singleRouterSetupNoBackhaul": "Конфигурация с одним маршрутизатором — без транзита", "sizeBytes": "Размер: {size} байт ({mib} MiB)", "skipped": "Пропущено", @@ -1003,15 +1032,10 @@ "trend": "Тенденция", "trends": "Тенденции", "triggerPorts": "Триггерные порты", - "triggeringWithCount": "Триггеры ({count})", "txPower": "Мощность передачи", "type": "Тип", "typeAMessage": "Введите сообщение...", "unableToGatherDeviceInfo": "Не удалось собрать информацию об устройстве", - "unableToLoadApps": "Не удалось загрузить приложения", - "unableToLoadDiagnostics": "Не удалось загрузить диагностику", - "unableToLoadSpeedTest": "Не удалось загрузить тест скорости", - "unableToLoadTopology": "Не удалось загрузить топологию", "unchangedLabel": "(Без изменений)", "unknownWidget": "Неизвестный виджет: {id}", "unnamed": "(без имени)", @@ -1099,5 +1123,12 @@ "targetChain": "Целевая цепочка", "synchronized": "Синхронизировано", "noPortForwardingRulesConfigured": "Правила переадресации портов не настроены", - "nItems": "{count, plural, one{{count} элемент} few{{count} элемента} many{{count} элементов} other{{count} элемента}}" + "nItems": "{count, plural, one{{count} элемент} few{{count} элемента} many{{count} элементов} other{{count} элемента}}", + "bridgeReconnectHint": "После сохранения снова подключитесь к маршрутизатору по адресу {url}.", + "bridgeRedirectTitle": "Повторное подключение к маршрутизатору", + "bridgeRedirectMessage": "Теперь ваш маршрутизатор работает как прозрачный мост и больше не назначает локальные IP-адреса. Снова подключитесь по адресу {url}.", + "bridgeRedirectButton": "Перейти к маршрутизатору", + "lanIpRedirectTitle": "Повторное подключение к маршрутизатору", + "lanIpRedirectMessage": "IP-адрес вашего маршрутизатора изменился. Повторно подключитесь к нему по адресу {url}. Возможно, придётся подождать, пока ваше устройство получит новый адрес.", + "lanIpRedirectButton": "Перейти к маршрутизатору" } diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index 39d8b295c..dde0b73de 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -126,6 +126,8 @@ "invalidDestinationMacAddress": "Ogiltig MAC-adress för destination", "invalidDns": "Ogiltig DNS", "invalidGatewayIpAddress": "Ogiltig IP-adress för gatewayen", + "gatewayMustBeWithinLanSubnet": "Gateway måste vara inom LAN-undernätet", + "gatewayMustBeOutsideLanSubnet": "Gateway måste vara utanför LAN-undernätet", "invalidInput": "Ogiltiga indata", "invalidIpAddress": "Ogiltig IP-adress", "invalidMACAddress": "Ogiltig MAC-adress.", @@ -134,6 +136,7 @@ "ipv4": "IPv4", "ipv6": "IPv6", "ipv6Address": "IPv6-adress", + "ipv6ScopeLinkLocal": "Länklokal", "keepAlive": "Behåll anslutning", "l2tpPassthrough": "L2TP-vidarekoppling", "labelInterface": "Gränssnitt", @@ -239,9 +242,11 @@ "prefix": "Prefix", "prefixLength": "Längd på prefixet", "print": "Skriv ut", + "privateMac": "Privat", "privacyAndSecurity": "Dataskydd och säkerhet", "processing": "Bearbetning...", "protocol": "Protokoll", + "publicMac": "Offentlig", "quickSetup": "Snabbinställning", "applyToAllBandsDesc": "Tillämpa samma WiFi-inställningar på alla band samtidigt", "rebootChildTitle": "Starta om noden och alla dess undernoder", @@ -380,7 +385,7 @@ "addDeviceManually": "Lägg till enhet manuellt", "addPortForwarding": "Lägg till portvidarebefordran", "addPortRangeForwarding": "Lägg till vidarebefordran för portintervall", - "addPortTriggering": "Lägg till porttriggning", + "addPortTriggering": "Lägg till porttriggning av intervall", "addedWidgetNamed": "{name} har lagts till", "adding": "Lägger till…", "additionalFiltersOnlineOnly": "Ytterligare filter är endast tillgängliga för enheter som är online.", @@ -434,6 +439,17 @@ "changeNetworkSettingsTitle": "Ändra nätverksinställningar?", "changeRouterPassword": "Ändra routerlösenord", "channelNumber": "Kanalnummer", + "channelAutoRecommended": "Auto (recommended)", + "channelCurrentlyUsing": "Currently using: {channel}", + "@channelCurrentlyUsing": { + "placeholders": { + "channel": { + "type": "int" + } + } + }, + "channelDfsSuffix": "· DFS", + "channelNoManualOptions": "No manual channels available for this band.", "channels": "Kanaler", "checkConnectivityAndSpeed": "Kontrollera anslutning och hastighet", "checkForUpdates": "Sök efter uppdateringar", @@ -462,6 +478,8 @@ "confirmationRequired": "Bekräftelse krävs", "confirmingNewFirmware": "Bekräftar att den nya fasta programvaran körs…", "connect": "Anslut", + "duplicateMacAddress": "Den här MAC-adressen är redan reserverad", + "duplicateIpAddress": "Den här IP-adressen är redan reserverad", "connectedDevices": "Anslutna enheter", "connecting": "Ansluter...", "connectingToRouter": "Ansluter till router...", @@ -546,6 +564,9 @@ "diagnosticsRecWeakWifiTitle": "Svag WiFi-signal", "disable": "Inaktivera", "disableInstantPrivacyDesc": "Alla enheter kommer att kunna ansluta fritt till ditt nätverk.", + "privateMacWarningTitle": "Vissa enheter använder en privat Wi-Fi-adress", + "privateMacWarningDesc": "Enheterna nedan använder en privat (slumpmässig) Wi-Fi-adress. Eftersom den här adressen ändras med tiden kan de blockeras efter att den roterar — även den du använder just nu. Stäng av \"Privat Wi-Fi-adress\" för dessa enheter innan du aktiverar Instant Privacy för att förbli ansluten.", + "privateMacLabel": "Privat", "disableInstantPrivacyTitle": "Inaktivera Instant Privacy?", "discards": "Kasserade", "distribution": "Fördelning", @@ -572,7 +593,7 @@ "dynamicFrequencySelection": "Dynamic Frequency Selection (DFS)", "editPortForwarding": "Redigera portvidarebefordran", "editPortRangeForwarding": "Redigera vidarebefordran för portintervall", - "editPortTriggering": "Redigera porttriggning", + "editPortTriggering": "Redigera porttriggning av intervall", "editRule": "Redigera regel", "editStaticRoute": "Redigera statisk rutt", "editTimeSettings": "Redigera tidsinställningar", @@ -596,7 +617,6 @@ "errorInvalidCredentials": "Felaktigt användarnamn eller lösenord.", "errorInvalidInput": "Det angivna värdet är ogiltigt. Kontrollera och försök igen.", "errorInvalidSessionToken": "Din session är ogiltig. Logga in igen.", - "errorLoadingSpeedTest": "Det gick inte att läsa in hastighetstestet", "errorNetwork": "Nätverksfel. Kontrollera din anslutning och försök igen.", "errorNotAuthenticated": "Du är inte inloggad. Logga in och försök igen.", "errorResourceNotFound": "Den begärda inställningen kunde inte hittas på routern.", @@ -763,6 +783,9 @@ "noAdvancedWifiSettings": "Inga avancerade WiFi-inställningar tillgängliga för den här enheten.", "noAnswersReturned": "Inga svar returnerades.", "noAppsInstalled": "Inga appar installerade på den här routern", + "unableToLoadApps": "Det gick inte att läsa in appar", + "unableToLoadHealthData": "Det gick inte att läsa in tillståndsdata", + "unableToLoadTopology": "Det gick inte att läsa in topologin", "noClients": "Inga klienter", "noDeviceActivityRecorded": "Ingen enhetsaktivitet registrerad", "noDevicesCurrentlyConnected": "Inga enheter är för närvarande anslutna.", @@ -780,7 +803,7 @@ "noLogFilesAvailable": "Inga loggfiler tillgängliga på den här routern", "noPortMappingsConfigured": "Inga portmappningar konfigurerade", "noPortRangeRules": "Inga regler för vidarebefordran av portintervall konfigurerade", - "noPortTriggeringRules": "Inga porttriggningsregler konfigurerade", + "noPortTriggeringRules": "Inga regler för porttriggning av intervall konfigurerade", "noPresetSelected": "Inget förinställt val", "noReservationIpMayChange": "Ingen reservation. IP kan ändras vid återanslutning.", "noSinglePortRules": "Inga regler för vidarebefordran av enskild port konfigurerade", @@ -836,9 +859,8 @@ "portMapping": "Portmappning", "portMappingSubtitle": "Regler för portvidarebefordran och DMZ-konfiguration", "portMustBe1To65535": "Porten måste vara 1–65535", - "portRangeWithCount": "Portintervall ({count})", "portRules": "Portregler", - "portTriggering": "Porttriggning", + "portTriggering": "Porttriggning av intervall", "ports": "Portar", "potentialIssues": "Potentiella problem", "pppStatus": "PPP-status", @@ -882,6 +904,12 @@ "renew": "Förnya", "requiredLabel": "(Obligatoriskt)", "reservationReleased": "Reservation frigjord", + "reservationAdded": "Reservation tillagd", + "reservationDeleted": "Reservation borttagen", + "reconnectedToRouter": "Återansluten till routern", + "timeSettingsSaved": "Tidsinställningar sparade", + "ruleAdded": "Regel tillagd", + "channelUpdated": "Kanal uppdaterad", "reservations": "Reservationer", "reserveIpAddress": "Reservera IP-adress", "reserved": "Reserverad", @@ -908,6 +936,8 @@ "routerRebootComplete": "Routeromstart slutförd", "routerWritingImage": "Routern skriver den nya avbildningen. Stäng inte av strömmen.", "rtColumn": "RT", + "unableToLoadDiagnostics": "Det gick inte att läsa in diagnostik", + "unableToLoadSpeedTest": "Det gick inte att läsa in hastighetstestet", "rules": "Regler", "runAgain": "Kör igen", "runDiagnosticsTitle": "Kör diagnostik", @@ -942,7 +972,6 @@ "signalQuality": "Signalkvalitet", "signalQualitySubtitle": "WiFi-signalkvalitet per frekvensband", "signalStrengthDbm": "{value} dBm", - "singlePortWithCount": "Enskild port ({count})", "singleRouterSetupNoBackhaul": "Konfiguration med en enda router – ingen backhaul", "sizeBytes": "Storlek: {size} byte ({mib} MiB)", "skipped": "Hoppades över", @@ -1003,15 +1032,10 @@ "trend": "Trend", "trends": "Trender", "triggerPorts": "Triggerportar", - "triggeringWithCount": "Triggning ({count})", "txPower": "Tx-effekt", "type": "Typ", "typeAMessage": "Skriv ett meddelande...", "unableToGatherDeviceInfo": "Det gick inte att samla in enhetsinformation", - "unableToLoadApps": "Det gick inte att läsa in appar", - "unableToLoadDiagnostics": "Det gick inte att läsa in diagnostik", - "unableToLoadSpeedTest": "Det gick inte att läsa in hastighetstestet", - "unableToLoadTopology": "Det gick inte att läsa in topologin", "unchangedLabel": "(Oförändrad)", "unknownWidget": "Okänd widget: {id}", "unnamed": "(namnlös)", @@ -1099,5 +1123,12 @@ "targetChain": "Målkedja", "synchronized": "Synkroniserad", "noPortForwardingRulesConfigured": "Inga regler för portvidarebefordran har konfigurerats", - "nItems": "{count, plural, one{{count} objekt} other{{count} objekt}}" + "nItems": "{count, plural, one{{count} objekt} other{{count} objekt}}", + "bridgeReconnectHint": "Efter att du har sparat ansluter du till din router igen på {url}.", + "bridgeRedirectTitle": "Anslut till din router igen", + "bridgeRedirectMessage": "Din router fungerar nu som en transparent brygga och delar inte längre ut lokala IP-adresser. Anslut igen på {url}.", + "bridgeRedirectButton": "Gå till routern", + "lanIpRedirectTitle": "Anslut till din router igen", + "lanIpRedirectMessage": "Routerns IP-adress har ändrats. Anslut till den igen på {url}. Du kan behöva vänta en stund tills din enhet får en ny adress.", + "lanIpRedirectButton": "Gå till routern" } diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index b94e314e1..8599f1050 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -126,6 +126,8 @@ "invalidDestinationMacAddress": "แอดเดรส MAC ปลายทางไม่ถูกต้อง", "invalidDns": "DNS ไม่ถูกต้อง", "invalidGatewayIpAddress": "IP แอดเดรสเกตเวย์ไม่ถูกต้อง", + "gatewayMustBeWithinLanSubnet": "เกตเวย์ต้องอยู่ภายในซับเน็ต LAN", + "gatewayMustBeOutsideLanSubnet": "เกตเวย์ต้องอยู่นอกซับเน็ต LAN", "invalidInput": "ป้อนข้อมูลไม่ถูกต้อง", "invalidIpAddress": "IP แอดเดรสไม่ถูกต้อง", "invalidMACAddress": "แอดเดรส MAC ไม่ถูกต้อง", @@ -134,6 +136,7 @@ "ipv4": "IPv4", "ipv6": "IPv6", "ipv6Address": "IPv6 แอดเดรส", + "ipv6ScopeLinkLocal": "ลิงก์-โลคัล", "keepAlive": "คงการเชื่อมต่อ", "l2tpPassthrough": "L2TP Passthrough", "labelInterface": "อินเตอร์เฟซ", @@ -237,9 +240,11 @@ "prefix": "คำนำหน้า", "prefixLength": "ความยาวคำนำหน้า", "print": "พิมพ์", + "privateMac": "ส่วนตัว", "privacyAndSecurity": "ความเป็นส่วนตัวและความปลอดภัย", "processing": "กำลังประมวลผล...", "protocol": "โปรโตคอล", + "publicMac": "สาธารณะ", "quickSetup": "การตั้งค่าด่วน", "applyToAllBandsDesc": "ใช้การตั้งค่า WiFi เดียวกันกับทุกย่านความถี่พร้อมกัน", "rebootChildTitle": "รีบูตโหนดและโหนดลูกทั้งหมด", @@ -378,7 +383,7 @@ "addDeviceManually": "เพิ่มอุปกรณ์ด้วยตนเอง", "addPortForwarding": "เพิ่มการส่งต่อพอร์ต", "addPortRangeForwarding": "เพิ่มการส่งต่อช่วงพอร์ต", - "addPortTriggering": "เพิ่มการทริกเกอร์พอร์ต", + "addPortTriggering": "เพิ่มการทริกเกอร์ช่วงพอร์ต", "addedWidgetNamed": "เพิ่ม {name} แล้ว", "adding": "กำลังเพิ่ม…", "additionalFiltersOnlineOnly": "ตัวกรองเพิ่มเติมใช้ได้เฉพาะกับอุปกรณ์ที่ออนไลน์เท่านั้น", @@ -432,6 +437,17 @@ "changeNetworkSettingsTitle": "เปลี่ยนการตั้งค่าเครือข่ายหรือไม่", "changeRouterPassword": "เปลี่ยนรหัสผ่านเราเตอร์", "channelNumber": "หมายเลขช่องสัญญาณ", + "channelAutoRecommended": "Auto (recommended)", + "channelCurrentlyUsing": "Currently using: {channel}", + "@channelCurrentlyUsing": { + "placeholders": { + "channel": { + "type": "int" + } + } + }, + "channelDfsSuffix": "· DFS", + "channelNoManualOptions": "No manual channels available for this band.", "channels": "ช่องสัญญาณ", "checkConnectivityAndSpeed": "ตรวจสอบการเชื่อมต่อและความเร็ว", "checkForUpdates": "ตรวจหาการอัปเดต", @@ -460,6 +476,8 @@ "confirmationRequired": "ต้องมีการยืนยัน", "confirmingNewFirmware": "กำลังยืนยันว่าเฟิร์มแวร์ใหม่กำลังทำงาน…", "connect": "เชื่อมต่อ", + "duplicateMacAddress": "ที่อยู่ MAC นี้ถูกสำรองไว้แล้ว", + "duplicateIpAddress": "ที่อยู่ IP นี้ถูกสำรองไว้แล้ว", "connectedDevices": "อุปกรณ์ที่เชื่อมต่อ", "connecting": "กำลังเชื่อมต่อ...", "connectingToRouter": "กำลังเชื่อมต่อกับเราเตอร์...", @@ -544,6 +562,9 @@ "diagnosticsRecWeakWifiTitle": "สัญญาณ WiFi อ่อน", "disable": "ปิดใช้งาน", "disableInstantPrivacyDesc": "อุปกรณ์ทั้งหมดจะสามารถเชื่อมต่อกับเครือข่ายของคุณได้อย่างอิสระ", + "privateMacWarningTitle": "อุปกรณ์บางเครื่องใช้ที่อยู่ Wi-Fi ส่วนตัว", + "privateMacWarningDesc": "อุปกรณ์ด้านล่างนี้ใช้ที่อยู่ Wi-Fi ส่วนตัว (แบบสุ่ม) เนื่องจากที่อยู่นี้เปลี่ยนแปลงไปตามเวลา อุปกรณ์เหล่านี้อาจถูกบล็อกหลังจากที่อยู่เปลี่ยน — แม้แต่เครื่องที่คุณกำลังใช้อยู่ตอนนี้ เพื่อให้เชื่อมต่ออยู่เสมอ โปรดปิด \"ที่อยู่ Wi-Fi ส่วนตัว\" สำหรับอุปกรณ์เหล่านี้ก่อนเปิดใช้งาน Instant Privacy", + "privateMacLabel": "ส่วนตัว", "disableInstantPrivacyTitle": "ปิดใช้งาน Instant Privacy หรือไม่", "discards": "การละทิ้ง", "distribution": "การกระจาย", @@ -570,7 +591,7 @@ "dynamicFrequencySelection": "Dynamic Frequency Selection (DFS)", "editPortForwarding": "แก้ไขการส่งต่อพอร์ต", "editPortRangeForwarding": "แก้ไขการส่งต่อช่วงพอร์ต", - "editPortTriggering": "แก้ไขการทริกเกอร์พอร์ต", + "editPortTriggering": "แก้ไขการทริกเกอร์ช่วงพอร์ต", "editRule": "แก้ไขกฎ", "editStaticRoute": "แก้ไขเส้นทางแบบสแตติก", "editTimeSettings": "แก้ไขการตั้งค่าเวลา", @@ -594,7 +615,6 @@ "errorInvalidCredentials": "ชื่อผู้ใช้หรือรหัสผ่านไม่ถูกต้อง", "errorInvalidInput": "ค่าที่ป้อนไม่ถูกต้อง กรุณาตรวจสอบและลองอีกครั้ง", "errorInvalidSessionToken": "เซสชันของคุณไม่ถูกต้อง กรุณาลงชื่อเข้าใช้อีกครั้ง", - "errorLoadingSpeedTest": "เกิดข้อผิดพลาดในการโหลดการทดสอบความเร็ว", "errorNetwork": "เกิดข้อผิดพลาดของเครือข่าย กรุณาตรวจสอบการเชื่อมต่อและลองอีกครั้ง", "errorNotAuthenticated": "คุณยังไม่ได้ลงชื่อเข้าใช้ กรุณาลงชื่อเข้าใช้และลองอีกครั้ง", "errorResourceNotFound": "ไม่พบการตั้งค่าที่ร้องขอบนเราเตอร์", @@ -763,6 +783,9 @@ "noAdvancedWifiSettings": "ไม่มีการตั้งค่า WiFi ขั้นสูงสำหรับอุปกรณ์นี้", "noAnswersReturned": "ไม่มีคำตอบกลับมา", "noAppsInstalled": "ไม่มีแอปติดตั้งบนเราเตอร์นี้", + "unableToLoadApps": "ไม่สามารถโหลดแอปได้", + "unableToLoadHealthData": "ไม่สามารถโหลดข้อมูลความสมบูรณ์ได้", + "unableToLoadTopology": "ไม่สามารถโหลดโทโพโลยีได้", "noClients": "ไม่มีไคลเอนต์", "noDeviceActivityRecorded": "ไม่มีการบันทึกกิจกรรมของอุปกรณ์", "noDevicesCurrentlyConnected": "ไม่มีอุปกรณ์เชื่อมต่ออยู่ในขณะนี้", @@ -780,7 +803,7 @@ "noLogFilesAvailable": "ไม่มีไฟล์บันทึกบนเราเตอร์นี้", "noPortMappingsConfigured": "ยังไม่มีการกำหนดค่าการแมปพอร์ต", "noPortRangeRules": "ยังไม่มีการกำหนดค่ากฎการส่งต่อช่วงพอร์ต", - "noPortTriggeringRules": "ยังไม่มีการกำหนดค่ากฎการทริกเกอร์พอร์ต", + "noPortTriggeringRules": "ไม่มีการกำหนดค่ากฎการทริกเกอร์ช่วงพอร์ต", "noPresetSelected": "ยังไม่ได้เลือกพรีเซ็ต", "noReservationIpMayChange": "ไม่มีการสำรอง IP อาจเปลี่ยนเมื่อเชื่อมต่อใหม่", "noSinglePortRules": "ยังไม่มีการกำหนดค่ากฎการส่งต่อพอร์ตเดี่ยว", @@ -836,9 +859,8 @@ "portMapping": "การแมปพอร์ต", "portMappingSubtitle": "กฎการส่งต่อพอร์ตและการกำหนดค่า DMZ", "portMustBe1To65535": "พอร์ตต้องอยู่ระหว่าง 1-65535", - "portRangeWithCount": "ช่วงพอร์ต ({count})", "portRules": "กฎพอร์ต", - "portTriggering": "การทริกเกอร์พอร์ต", + "portTriggering": "การทริกเกอร์ช่วงพอร์ต", "ports": "พอร์ต", "potentialIssues": "ปัญหาที่อาจเกิดขึ้น", "pppStatus": "สถานะ PPP", @@ -882,6 +904,12 @@ "renew": "ต่ออายุ", "requiredLabel": "(จำเป็น)", "reservationReleased": "ปล่อยการสำรองแล้ว", + "reservationAdded": "เพิ่มการสำรองแล้ว", + "reservationDeleted": "ลบการสำรองแล้ว", + "reconnectedToRouter": "เชื่อมต่อกับเราเตอร์อีกครั้งแล้ว", + "timeSettingsSaved": "บันทึกการตั้งค่าเวลาแล้ว", + "ruleAdded": "เพิ่มกฎแล้ว", + "channelUpdated": "อัปเดตช่องสัญญาณแล้ว", "reservations": "การสำรอง", "reserveIpAddress": "สำรองที่อยู่ IP", "reserved": "สำรองไว้", @@ -908,6 +936,8 @@ "routerRebootComplete": "รีบูตเราเตอร์เสร็จสมบูรณ์", "routerWritingImage": "เราเตอร์กำลังเขียนอิมเมจใหม่ อย่าปิดเครื่อง", "rtColumn": "RT", + "unableToLoadDiagnostics": "ไม่สามารถโหลดการวินิจฉัยได้", + "unableToLoadSpeedTest": "ไม่สามารถโหลดการทดสอบความเร็วได้", "rules": "กฎ", "runAgain": "เรียกใช้อีกครั้ง", "runDiagnosticsTitle": "เรียกใช้การวินิจฉัย", @@ -942,7 +972,6 @@ "signalQuality": "คุณภาพสัญญาณ", "signalQualitySubtitle": "คุณภาพสัญญาณ WiFi ตามแบนด์ความถี่", "signalStrengthDbm": "{value} dBm", - "singlePortWithCount": "พอร์ตเดี่ยว ({count})", "singleRouterSetupNoBackhaul": "การตั้งค่าเราเตอร์เดี่ยว — ไม่มี backhaul", "sizeBytes": "ขนาด: {size} ไบต์ ({mib} MiB)", "skipped": "ข้ามแล้ว", @@ -1003,15 +1032,10 @@ "trend": "แนวโน้ม", "trends": "แนวโน้ม", "triggerPorts": "พอร์ตทริกเกอร์", - "triggeringWithCount": "การทริกเกอร์ ({count})", "txPower": "กำลังส่ง", "type": "ประเภท", "typeAMessage": "พิมพ์ข้อความ...", "unableToGatherDeviceInfo": "ไม่สามารถรวบรวมข้อมูลอุปกรณ์ได้", - "unableToLoadApps": "ไม่สามารถโหลดแอปได้", - "unableToLoadDiagnostics": "ไม่สามารถโหลดการวินิจฉัยได้", - "unableToLoadSpeedTest": "ไม่สามารถโหลดการทดสอบความเร็วได้", - "unableToLoadTopology": "ไม่สามารถโหลดโทโพโลยีได้", "unchangedLabel": "(ไม่เปลี่ยนแปลง)", "unknownWidget": "วิดเจ็ตที่ไม่รู้จัก: {id}", "unnamed": "(ไม่มีชื่อ)", @@ -1099,5 +1123,12 @@ "targetChain": "เชนเป้าหมาย", "synchronized": "ซิงโครไนซ์แล้ว", "noPortForwardingRulesConfigured": "ไม่มีการกำหนดค่ากฎการส่งต่อพอร์ต", - "nItems": "{count, plural, other{{count} รายการ}}" + "nItems": "{count, plural, other{{count} รายการ}}", + "bridgeReconnectHint": "หลังจากบันทึกแล้ว โปรดเชื่อมต่อกับเราเตอร์ของคุณอีกครั้งที่ {url}", + "bridgeRedirectTitle": "เชื่อมต่อกับเราเตอร์ของคุณอีกครั้ง", + "bridgeRedirectMessage": "ขณะนี้เราเตอร์ของคุณทำงานเป็นบริดจ์แบบโปร่งใสและไม่แจกจ่ายที่อยู่ IP ภายในอีกต่อไป โปรดเชื่อมต่อใหม่ที่ {url}", + "bridgeRedirectButton": "ไปที่เราเตอร์", + "lanIpRedirectTitle": "เชื่อมต่อกับเราเตอร์ของคุณอีกครั้ง", + "lanIpRedirectMessage": "ที่อยู่ IP ของเราเตอร์ของคุณเปลี่ยนแปลงแล้ว เชื่อมต่อกับเราเตอร์อีกครั้งที่ {url} คุณอาจต้องรอสักครู่ให้อุปกรณ์ของคุณได้รับที่อยู่ใหม่", + "lanIpRedirectButton": "ไปที่เราเตอร์" } diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index 622ee4918..8d7110d06 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -126,6 +126,8 @@ "invalidDestinationMacAddress": "Geçersiz hedef MAC adresi", "invalidDns": "Geçersiz DNS", "invalidGatewayIpAddress": "Geçersiz ağ geçidi IP adresi", + "gatewayMustBeWithinLanSubnet": "Ağ geçidi LAN alt ağı içinde olmalıdır", + "gatewayMustBeOutsideLanSubnet": "Ağ geçidi LAN alt ağı dışında olmalıdır", "invalidInput": "Geçersiz giriş", "invalidIpAddress": "Geçersiz IP adresi", "invalidMACAddress": "Geçersiz MAC adresi.", @@ -134,6 +136,7 @@ "ipv4": "IPv4", "ipv6": "IPv6", "ipv6Address": "IPv6 Adresi", + "ipv6ScopeLinkLocal": "Bağlantı yerel", "keepAlive": "Bağlı tut", "l2tpPassthrough": "L2TP Geçiş İzni", "labelInterface": "Arabirim", @@ -237,9 +240,11 @@ "prefix": "Önek", "prefixLength": "Önek uzunluğu", "print": "Yazdır", + "privateMac": "Özel", "privacyAndSecurity": "Gizlilik ve Güvenlik", "processing": "İşleniyor...", "protocol": "Protokol", + "publicMac": "Genel", "quickSetup": "Hızlı Kurulum", "applyToAllBandsDesc": "Aynı WiFi ayarlarını tüm bantlara aynı anda uygulayın", "rebootChildTitle": "Düğümü ve tüm alt düğümlerini yeniden başlat", @@ -378,7 +383,7 @@ "addDeviceManually": "Cihazı manuel olarak ekle", "addPortForwarding": "Port Yönlendirme Ekle", "addPortRangeForwarding": "Port Aralığı Yönlendirme Ekle", - "addPortTriggering": "Port Tetikleme Ekle", + "addPortTriggering": "Port Aralığı Tetikleme Ekle", "addedWidgetNamed": "{name} eklendi", "adding": "Ekleniyor…", "additionalFiltersOnlineOnly": "Ek filtreler yalnızca çevrimiçi cihazlar için kullanılabilir.", @@ -432,6 +437,17 @@ "changeNetworkSettingsTitle": "Ağ Ayarları Değiştirilsin mi?", "changeRouterPassword": "Yönlendirici Parolasını Değiştir", "channelNumber": "Kanal numarası", + "channelAutoRecommended": "Auto (recommended)", + "channelCurrentlyUsing": "Currently using: {channel}", + "@channelCurrentlyUsing": { + "placeholders": { + "channel": { + "type": "int" + } + } + }, + "channelDfsSuffix": "· DFS", + "channelNoManualOptions": "No manual channels available for this band.", "channels": "Kanallar", "checkConnectivityAndSpeed": "Bağlantıyı ve hızı kontrol et", "checkForUpdates": "Güncellemeleri Denetle", @@ -460,6 +476,8 @@ "confirmationRequired": "Onay Gerekli", "confirmingNewFirmware": "Yeni bellenimin çalıştığı doğrulanıyor…", "connect": "Bağlan", + "duplicateMacAddress": "Bu MAC adresi zaten ayrılmış", + "duplicateIpAddress": "Bu IP adresi zaten ayrılmış", "connectedDevices": "Bağlı Cihazlar", "connecting": "Bağlanıyor...", "connectingToRouter": "Yönlendiriciye bağlanılıyor...", @@ -544,6 +562,9 @@ "diagnosticsRecWeakWifiTitle": "Zayıf WiFi Sinyali", "disable": "Devre Dışı Bırak", "disableInstantPrivacyDesc": "Tüm cihazlar ağınıza serbestçe bağlanabilecek.", + "privateMacWarningTitle": "Bazı cihazlar özel bir Wi-Fi adresi kullanıyor", + "privateMacWarningDesc": "Aşağıdaki cihazlar özel (rastgele) bir Wi-Fi adresi kullanıyor. Bu adres zamanla değiştiğinden, adres değiştikten sonra engellenebilirler — şu anda kullandığınız cihaz da dahil. Bağlı kalmak için Anında Gizlilik’i etkinleştirmeden önce bu cihazlarda \"Özel Wi-Fi Adresi\" ayarını kapatın.", + "privateMacLabel": "Özel", "disableInstantPrivacyTitle": "Anında Gizlilik Devre Dışı Bırakılsın mı?", "discards": "Atılanlar", "distribution": "Dağılım", @@ -570,7 +591,7 @@ "dynamicFrequencySelection": "Dinamik Frekans Seçimi (DFS)", "editPortForwarding": "Port Yönlendirmeyi Düzenle", "editPortRangeForwarding": "Port Aralığı Yönlendirmeyi Düzenle", - "editPortTriggering": "Port Tetiklemeyi Düzenle", + "editPortTriggering": "Port Aralığı Tetiklemeyi Düzenle", "editRule": "Kuralı düzenle", "editStaticRoute": "Statik Yolu Düzenle", "editTimeSettings": "Saat ayarlarını düzenle", @@ -594,7 +615,6 @@ "errorInvalidCredentials": "Yanlış kullanıcı adı veya parola.", "errorInvalidInput": "Girilen değer geçerli değil. Lütfen kontrol edip tekrar deneyin.", "errorInvalidSessionToken": "Oturumunuz geçersiz. Lütfen tekrar oturum açın.", - "errorLoadingSpeedTest": "Hız testi yüklenirken hata oluştu", "errorNetwork": "Ağ hatası. Lütfen bağlantınızı kontrol edip tekrar deneyin.", "errorNotAuthenticated": "Oturum açmadınız. Lütfen oturum açıp tekrar deneyin.", "errorResourceNotFound": "İstenen ayar yönlendiricide bulunamadı.", @@ -763,6 +783,9 @@ "noAdvancedWifiSettings": "Bu cihaz için kullanılabilir gelişmiş WiFi ayarı yok.", "noAnswersReturned": "Hiçbir yanıt dönmedi.", "noAppsInstalled": "Bu yönlendiricide yüklü uygulama yok", + "unableToLoadApps": "Uygulamalar yüklenemedi", + "unableToLoadHealthData": "Durum verileri yüklenemedi", + "unableToLoadTopology": "Topoloji yüklenemedi", "noClients": "İstemci yok", "noDeviceActivityRecorded": "Kaydedilmiş cihaz etkinliği yok", "noDevicesCurrentlyConnected": "Şu anda bağlı cihaz yok.", @@ -780,7 +803,7 @@ "noLogFilesAvailable": "Bu yönlendiricide kullanılabilir günlük dosyası yok", "noPortMappingsConfigured": "Yapılandırılmış port eşlemesi yok", "noPortRangeRules": "Yapılandırılmış port aralığı yönlendirme kuralı yok", - "noPortTriggeringRules": "Yapılandırılmış port tetikleme kuralı yok", + "noPortTriggeringRules": "Yapılandırılmış port aralığı tetikleme kuralı yok", "noPresetSelected": "Önceden ayarlanmış değer seçilmedi", "noReservationIpMayChange": "Ayırma yok. Yeniden bağlantıda IP değişebilir.", "noSinglePortRules": "Yapılandırılmış tek port yönlendirme kuralı yok", @@ -836,9 +859,8 @@ "portMapping": "Port Eşleme", "portMappingSubtitle": "Port yönlendirme kuralları ve DMZ yapılandırması", "portMustBe1To65535": "Port 1-65535 arasında olmalıdır", - "portRangeWithCount": "Port Aralığı ({count})", "portRules": "Port Kuralları", - "portTriggering": "Port Tetikleme", + "portTriggering": "Port Aralığı Tetikleme", "ports": "Portlar", "potentialIssues": "Olası Sorunlar", "pppStatus": "PPP Durumu", @@ -882,6 +904,12 @@ "renew": "Yenile", "requiredLabel": "(Gerekli)", "reservationReleased": "Ayırma serbest bırakıldı", + "reservationAdded": "Ayırma eklendi", + "reservationDeleted": "Ayırma silindi", + "reconnectedToRouter": "Yönlendiriciye yeniden bağlanıldı", + "timeSettingsSaved": "Saat ayarları kaydedildi", + "ruleAdded": "Kural eklendi", + "channelUpdated": "Kanal güncellendi", "reservations": "Ayırmalar", "reserveIpAddress": "IP Adresi Ayır", "reserved": "Ayrıldı", @@ -908,6 +936,8 @@ "routerRebootComplete": "Yönlendirici yeniden başlatması tamamlandı", "routerWritingImage": "Yönlendirici yeni imajı yazıyor. Gücü kapatmayın.", "rtColumn": "RT", + "unableToLoadDiagnostics": "Tanılama yüklenemedi", + "unableToLoadSpeedTest": "Hız testi yüklenemedi", "rules": "Kurallar", "runAgain": "Tekrar Çalıştır", "runDiagnosticsTitle": "Tanılamayı Çalıştır", @@ -942,7 +972,6 @@ "signalQuality": "Sinyal Kalitesi", "signalQualitySubtitle": "Frekans bandına göre WiFi sinyal kalitesi", "signalStrengthDbm": "{value} dBm", - "singlePortWithCount": "Tek Port ({count})", "singleRouterSetupNoBackhaul": "Tek yönlendirici kurulumu — ana hat yok", "sizeBytes": "Boyut: {size} bayt ({mib} MiB)", "skipped": "Atlandı", @@ -1003,15 +1032,10 @@ "trend": "Eğilim", "trends": "Eğilimler", "triggerPorts": "Tetikleme Portları", - "triggeringWithCount": "Tetikleme ({count})", "txPower": "Tx Gücü", "type": "Tür", "typeAMessage": "Bir mesaj yazın...", "unableToGatherDeviceInfo": "Cihaz bilgileri toplanamadı", - "unableToLoadApps": "Uygulamalar yüklenemedi", - "unableToLoadDiagnostics": "Tanılama yüklenemedi", - "unableToLoadSpeedTest": "Hız testi yüklenemedi", - "unableToLoadTopology": "Topoloji yüklenemedi", "unchangedLabel": "(Değişmedi)", "unknownWidget": "Bilinmeyen widget: {id}", "unnamed": "(adsız)", @@ -1099,5 +1123,12 @@ "targetChain": "Hedef Zinciri", "synchronized": "Eşitlendi", "noPortForwardingRulesConfigured": "Yapılandırılmış bağlantı noktası yönlendirme kuralı yok", - "nItems": "{count, plural, one{{count} öğe} other{{count} öğe}}" + "nItems": "{count, plural, one{{count} öğe} other{{count} öğe}}", + "bridgeReconnectHint": "Kaydettikten sonra {url} adresinden yönlendiricinize yeniden bağlanın.", + "bridgeRedirectTitle": "Yönlendiricinize yeniden bağlanın", + "bridgeRedirectMessage": "Yönlendiriciniz artık şeffaf bir köprü olarak çalışıyor ve yerel IP adresleri dağıtmıyor. {url} adresinden yeniden bağlanın.", + "bridgeRedirectButton": "Yönlendiriciye git", + "lanIpRedirectTitle": "Yönlendiricinize yeniden bağlanın", + "lanIpRedirectMessage": "Yönlendiricinizin IP adresi değişti. {url} adresinden ona yeniden bağlanın. Cihazınızın yeni bir adres alması için bir süre beklemeniz gerekebilir.", + "lanIpRedirectButton": "Yönlendiriciye git" } diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 84a042efe..280f71085 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -126,6 +126,8 @@ "invalidDestinationMacAddress": "Địa chỉ MAC đích không hợp lệ", "invalidDns": "DNS không hợp lệ", "invalidGatewayIpAddress": "Địa chỉ IP cổng không hợp lệ", + "gatewayMustBeWithinLanSubnet": "Cổng phải nằm trong mạng con LAN", + "gatewayMustBeOutsideLanSubnet": "Cổng phải nằm ngoài mạng con LAN", "invalidInput": "Dữ liệu nhập không hợp lệ", "invalidIpAddress": "Địa chỉ IP không hợp lệ", "invalidMACAddress": "Địa chỉ MAC không hợp lệ.", @@ -134,6 +136,7 @@ "ipv4": "IPv4", "ipv6": "IPv6", "ipv6Address": "Địa chỉ IPv6", + "ipv6ScopeLinkLocal": "Link-local", "keepAlive": "Giữ hoạt động", "l2tpPassthrough": "L2TP Truyền qua", "labelInterface": "Giao diện", @@ -237,9 +240,11 @@ "prefix": "Tiền tố", "prefixLength": "Độ dài tiền tố", "print": "In", + "privateMac": "Riêng tư", "privacyAndSecurity": "Riêng tư & Bảo mật", "processing": "Đang xử lý...", "protocol": "Giao thức", + "publicMac": "Công khai", "quickSetup": "Thiết lập nhanh", "applyToAllBandsDesc": "Áp dụng cùng một cài đặt WiFi cho tất cả các băng tần cùng một lúc", "rebootChildTitle": "Khởi động lại nút và tất cả các nút con", @@ -378,7 +383,7 @@ "addDeviceManually": "Thêm thiết bị thủ công", "addPortForwarding": "Thêm chuyển tiếp cổng", "addPortRangeForwarding": "Thêm chuyển tiếp dải cổng", - "addPortTriggering": "Thêm kích hoạt cổng", + "addPortTriggering": "Thêm kích hoạt dải cổng", "addedWidgetNamed": "Đã thêm {name}", "adding": "Đang thêm…", "additionalFiltersOnlineOnly": "Các bộ lọc bổ sung chỉ khả dụng cho thiết bị trực tuyến.", @@ -432,6 +437,17 @@ "changeNetworkSettingsTitle": "Thay đổi cài đặt mạng?", "changeRouterPassword": "Đổi mật khẩu router", "channelNumber": "Số kênh", + "channelAutoRecommended": "Auto (recommended)", + "channelCurrentlyUsing": "Currently using: {channel}", + "@channelCurrentlyUsing": { + "placeholders": { + "channel": { + "type": "int" + } + } + }, + "channelDfsSuffix": "· DFS", + "channelNoManualOptions": "No manual channels available for this band.", "channels": "Kênh", "checkConnectivityAndSpeed": "Kiểm tra kết nối và tốc độ", "checkForUpdates": "Kiểm tra cập nhật", @@ -460,6 +476,8 @@ "confirmationRequired": "Cần xác nhận", "confirmingNewFirmware": "Đang xác nhận firmware mới đang chạy…", "connect": "Kết nối", + "duplicateMacAddress": "Địa chỉ MAC này đã được dành riêng", + "duplicateIpAddress": "Địa chỉ IP này đã được dành riêng", "connectedDevices": "Thiết bị được kết nối", "connecting": "Đang kết nối...", "connectingToRouter": "Đang kết nối với router...", @@ -544,6 +562,9 @@ "diagnosticsRecWeakWifiTitle": "Tín hiệu WiFi yếu", "disable": "Tắt", "disableInstantPrivacyDesc": "Mọi thiết bị sẽ có thể kết nối tự do với mạng của bạn.", + "privateMacWarningTitle": "Một số thiết bị sử dụng địa chỉ Wi-Fi riêng tư", + "privateMacWarningDesc": "Các thiết bị bên dưới sử dụng địa chỉ Wi-Fi riêng tư (ngẫu nhiên). Vì địa chỉ này thay đổi theo thời gian, chúng có thể bị chặn sau khi địa chỉ thay đổi — kể cả thiết bị bạn đang dùng ngay bây giờ. Để duy trì kết nối, hãy tắt \"Địa chỉ Wi-Fi riêng\" cho các thiết bị này trước khi bật Quyền riêng tư tức thì.", + "privateMacLabel": "Riêng tư", "disableInstantPrivacyTitle": "Tắt Quyền riêng tư tức thì?", "discards": "Loại bỏ", "distribution": "Phân bố", @@ -570,7 +591,7 @@ "dynamicFrequencySelection": "Lựa chọn tần số động (DFS)", "editPortForwarding": "Chỉnh sửa chuyển tiếp cổng", "editPortRangeForwarding": "Chỉnh sửa chuyển tiếp dải cổng", - "editPortTriggering": "Chỉnh sửa kích hoạt cổng", + "editPortTriggering": "Chỉnh sửa kích hoạt dải cổng", "editRule": "Chỉnh sửa quy tắc", "editStaticRoute": "Chỉnh sửa tuyến tĩnh", "editTimeSettings": "Chỉnh sửa cài đặt thời gian", @@ -594,7 +615,6 @@ "errorInvalidCredentials": "Tên người dùng hoặc mật khẩu không đúng.", "errorInvalidInput": "Giá trị đã nhập không hợp lệ. Vui lòng kiểm tra và thử lại.", "errorInvalidSessionToken": "Phiên của bạn không hợp lệ. Vui lòng đăng nhập lại.", - "errorLoadingSpeedTest": "Lỗi khi tải kiểm tra tốc độ", "errorNetwork": "Lỗi mạng. Vui lòng kiểm tra kết nối của bạn và thử lại.", "errorNotAuthenticated": "Bạn chưa đăng nhập. Vui lòng đăng nhập và thử lại.", "errorResourceNotFound": "Không tìm thấy cài đặt được yêu cầu trên router.", @@ -763,6 +783,9 @@ "noAdvancedWifiSettings": "Không có cài đặt WiFi nâng cao cho thiết bị này.", "noAnswersReturned": "Không có phản hồi nào được trả về.", "noAppsInstalled": "Không có ứng dụng nào được cài đặt trên router này", + "unableToLoadApps": "Không thể tải ứng dụng", + "unableToLoadHealthData": "Không thể tải dữ liệu tình trạng", + "unableToLoadTopology": "Không thể tải cấu trúc mạng", "noClients": "Không có máy khách", "noDeviceActivityRecorded": "Không có hoạt động thiết bị nào được ghi lại", "noDevicesCurrentlyConnected": "Hiện không có thiết bị nào được kết nối.", @@ -780,7 +803,7 @@ "noLogFilesAvailable": "Không có tệp nhật ký nào trên router này", "noPortMappingsConfigured": "Chưa cấu hình ánh xạ cổng nào", "noPortRangeRules": "Chưa cấu hình quy tắc chuyển tiếp dải cổng nào", - "noPortTriggeringRules": "Chưa cấu hình quy tắc kích hoạt cổng nào", + "noPortTriggeringRules": "Không có quy tắc kích hoạt dải cổng nào được cấu hình", "noPresetSelected": "Chưa chọn cài đặt sẵn", "noReservationIpMayChange": "Không có địa chỉ dành riêng. IP có thể thay đổi khi kết nối lại.", "noSinglePortRules": "Chưa cấu hình quy tắc chuyển tiếp cổng đơn nào", @@ -836,9 +859,8 @@ "portMapping": "Ánh xạ cổng", "portMappingSubtitle": "Quy tắc chuyển tiếp cổng và cấu hình DMZ", "portMustBe1To65535": "Cổng phải từ 1-65535", - "portRangeWithCount": "Dải cổng ({count})", "portRules": "Quy tắc cổng", - "portTriggering": "Kích hoạt cổng", + "portTriggering": "Kích hoạt dải cổng", "ports": "Cổng", "potentialIssues": "Sự cố tiềm ẩn", "pppStatus": "Trạng thái PPP", @@ -882,6 +904,12 @@ "renew": "Gia hạn", "requiredLabel": "(Bắt buộc)", "reservationReleased": "Đã giải phóng địa chỉ dành riêng", + "reservationAdded": "Đã thêm địa chỉ dành riêng", + "reservationDeleted": "Đã xóa địa chỉ dành riêng", + "reconnectedToRouter": "Đã kết nối lại với router", + "timeSettingsSaved": "Đã lưu cài đặt thời gian", + "ruleAdded": "Đã thêm quy tắc", + "channelUpdated": "Đã cập nhật kênh", "reservations": "Địa chỉ dành riêng", "reserveIpAddress": "Dành riêng địa chỉ IP", "reserved": "Đã dành riêng", @@ -908,6 +936,8 @@ "routerRebootComplete": "Khởi động lại router hoàn tất", "routerWritingImage": "Router đang ghi ảnh mới. Không tắt nguồn.", "rtColumn": "RT", + "unableToLoadDiagnostics": "Không thể tải chẩn đoán", + "unableToLoadSpeedTest": "Không thể tải kiểm tra tốc độ", "rules": "Quy tắc", "runAgain": "Chạy lại", "runDiagnosticsTitle": "Chạy chẩn đoán", @@ -942,7 +972,6 @@ "signalQuality": "Chất lượng tín hiệu", "signalQualitySubtitle": "Chất lượng tín hiệu WiFi theo băng tần", "signalStrengthDbm": "{value} dBm", - "singlePortWithCount": "Cổng đơn ({count})", "singleRouterSetupNoBackhaul": "Thiết lập router đơn — không có backhaul", "sizeBytes": "Kích thước: {size} byte ({mib} MiB)", "skipped": "Đã bỏ qua", @@ -1003,15 +1032,10 @@ "trend": "Xu hướng", "trends": "Xu hướng", "triggerPorts": "Cổng kích hoạt", - "triggeringWithCount": "Kích hoạt ({count})", "txPower": "Công suất phát", "type": "Loại", "typeAMessage": "Nhập tin nhắn...", "unableToGatherDeviceInfo": "Không thể thu thập thông tin thiết bị", - "unableToLoadApps": "Không thể tải ứng dụng", - "unableToLoadDiagnostics": "Không thể tải chẩn đoán", - "unableToLoadSpeedTest": "Không thể tải kiểm tra tốc độ", - "unableToLoadTopology": "Không thể tải cấu trúc mạng", "unchangedLabel": "(Không thay đổi)", "unknownWidget": "Tiện ích không xác định: {id}", "unnamed": "(chưa đặt tên)", @@ -1099,5 +1123,12 @@ "targetChain": "Chuỗi đích", "synchronized": "Đã đồng bộ hóa", "noPortForwardingRulesConfigured": "Chưa cấu hình quy tắc chuyển tiếp cổng nào", - "nItems": "{count, plural, other{{count} mục}}" + "nItems": "{count, plural, other{{count} mục}}", + "bridgeReconnectHint": "Sau khi lưu, hãy kết nối lại với bộ định tuyến của bạn tại {url}.", + "bridgeRedirectTitle": "Kết nối lại với bộ định tuyến của bạn", + "bridgeRedirectMessage": "Bộ định tuyến của bạn hiện là cầu nối trong suốt và không còn cấp địa chỉ IP cục bộ nữa. Hãy kết nối lại tại {url}.", + "bridgeRedirectButton": "Đi tới bộ định tuyến", + "lanIpRedirectTitle": "Kết nối lại với bộ định tuyến của bạn", + "lanIpRedirectMessage": "Địa chỉ IP của bộ định tuyến đã thay đổi. Kết nối lại với bộ định tuyến tại {url}. Bạn có thể cần đợi một lát để thiết bị của mình nhận được địa chỉ mới.", + "lanIpRedirectButton": "Đi tới bộ định tuyến" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 2d39b05fb..540432f2c 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -126,6 +126,8 @@ "invalidDestinationMacAddress": "目标MAC地址无效", "invalidDns": "无效的DNS", "invalidGatewayIpAddress": "网关IP地址无效", + "gatewayMustBeWithinLanSubnet": "网关必须在 LAN 子网内", + "gatewayMustBeOutsideLanSubnet": "网关必须在 LAN 子网外", "invalidInput": "输入无效", "invalidIpAddress": "无效的IP地址", "invalidMACAddress": "无效的MAC地址。", @@ -134,6 +136,7 @@ "ipv4": "IPv4", "ipv6": "IPv6", "ipv6Address": "IPv6地址", + "ipv6ScopeLinkLocal": "链路本地", "keepAlive": "保持活跃", "l2tpPassthrough": "L2TP通道", "labelInterface": "接口", @@ -240,9 +243,11 @@ "prefix": "前缀", "prefixLength": "前缀长度", "print": "打印", + "privateMac": "私有", "privacyAndSecurity": "隐私与安全", "processing": "处理中...", "protocol": "协议", + "publicMac": "公开", "quickSetup": "快速设置", "applyToAllBandsDesc": "同时将相同的 WiFi 设置应用到所有频段", "rebootChildTitle": "重启节点及其所有子节点", @@ -381,7 +386,7 @@ "addDeviceManually": "手动添加设备", "addPortForwarding": "添加端口转发", "addPortRangeForwarding": "添加端口范围转发", - "addPortTriggering": "添加端口触发", + "addPortTriggering": "添加端口范围触发", "addedWidgetNamed": "已添加 {name}", "adding": "添加中…", "additionalFiltersOnlineOnly": "其他筛选条件仅适用于在线设备。", @@ -435,6 +440,17 @@ "changeNetworkSettingsTitle": "更改网络设置?", "changeRouterPassword": "更改路由器密码", "channelNumber": "信道编号", + "channelAutoRecommended": "Auto (recommended)", + "channelCurrentlyUsing": "Currently using: {channel}", + "@channelCurrentlyUsing": { + "placeholders": { + "channel": { + "type": "int" + } + } + }, + "channelDfsSuffix": "· DFS", + "channelNoManualOptions": "No manual channels available for this band.", "channels": "信道", "checkConnectivityAndSpeed": "检查连接和速度", "checkForUpdates": "检查更新", @@ -463,6 +479,8 @@ "confirmationRequired": "需要确认", "confirmingNewFirmware": "正在确认新固件是否运行…", "connect": "连接", + "duplicateMacAddress": "此 MAC 地址已被保留", + "duplicateIpAddress": "此 IP 地址已被保留", "connectedDevices": "已连接的设备", "connecting": "连接中…", "connectingToRouter": "正在连接路由器…", @@ -547,6 +565,9 @@ "diagnosticsRecWeakWifiTitle": "WiFi 信号弱", "disable": "禁用", "disableInstantPrivacyDesc": "所有设备都将能够自由连接到您的网络。", + "privateMacWarningTitle": "部分设备使用私有 Wi-Fi 地址", + "privateMacWarningDesc": "以下设备使用私有(随机)Wi-Fi 地址。由于该地址会随时间变化,轮换后这些设备可能会被屏蔽——即使是您现在正在使用的设备。为保持连接,请在启用即时隐私前,关闭这些设备的“私有 Wi-Fi 地址”。", + "privateMacLabel": "私有", "disableInstantPrivacyTitle": "禁用即时隐私?", "discards": "丢弃", "distribution": "分布", @@ -573,7 +594,7 @@ "dynamicFrequencySelection": "动态频率选择 (DFS)", "editPortForwarding": "编辑端口转发", "editPortRangeForwarding": "编辑端口范围转发", - "editPortTriggering": "编辑端口触发", + "editPortTriggering": "编辑端口范围触发", "editRule": "编辑规则", "editStaticRoute": "编辑静态路由", "editTimeSettings": "编辑时间设置", @@ -597,7 +618,6 @@ "errorInvalidCredentials": "用户名或密码不正确。", "errorInvalidInput": "输入的值无效。请检查后重试。", "errorInvalidSessionToken": "您的会话无效。请重新登录。", - "errorLoadingSpeedTest": "加载速度测试出错", "errorNetwork": "网络错误。请检查您的连接后重试。", "errorNotAuthenticated": "您尚未登录。请登录后重试。", "errorResourceNotFound": "在路由器上找不到请求的设置。", @@ -763,6 +783,9 @@ "noAdvancedWifiSettings": "此设备没有可用的高级 WiFi 设置。", "noAnswersReturned": "未返回任何应答。", "noAppsInstalled": "此路由器上未安装任何应用", + "unableToLoadApps": "无法加载应用", + "unableToLoadHealthData": "无法加载健康状况数据", + "unableToLoadTopology": "无法加载拓扑", "noClients": "无客户端", "noDeviceActivityRecorded": "未记录任何设备活动", "noDevicesCurrentlyConnected": "当前没有设备连接。", @@ -780,7 +803,7 @@ "noLogFilesAvailable": "此路由器上没有可用的日志文件", "noPortMappingsConfigured": "未配置端口映射", "noPortRangeRules": "未配置端口范围转发规则", - "noPortTriggeringRules": "未配置端口触发规则", + "noPortTriggeringRules": "未配置端口范围触发规则", "noPresetSelected": "未选择预设", "noReservationIpMayChange": "无保留。重新连接时 IP 可能更改。", "noSinglePortRules": "未配置单端口转发规则", @@ -836,9 +859,8 @@ "portMapping": "端口映射", "portMappingSubtitle": "端口转发规则和 DMZ 配置", "portMustBe1To65535": "端口必须为 1-65535", - "portRangeWithCount": "端口范围({count})", "portRules": "端口规则", - "portTriggering": "端口触发", + "portTriggering": "端口范围触发", "ports": "端口", "potentialIssues": "潜在问题", "pppStatus": "PPP 状态", @@ -882,6 +904,12 @@ "renew": "续订", "requiredLabel": "(必填)", "reservationReleased": "保留已释放", + "reservationAdded": "保留已添加", + "reservationDeleted": "保留已删除", + "reconnectedToRouter": "已重新连接到路由器", + "timeSettingsSaved": "时间设置已保存", + "ruleAdded": "规则已添加", + "channelUpdated": "信道已更新", "reservations": "保留", "reserveIpAddress": "保留 IP 地址", "reserved": "已保留", @@ -908,6 +936,8 @@ "routerRebootComplete": "路由器重启完成", "routerWritingImage": "路由器正在写入新镜像。请勿断电。", "rtColumn": "RT", + "unableToLoadDiagnostics": "无法加载诊断", + "unableToLoadSpeedTest": "无法加载速度测试", "rules": "规则", "runAgain": "再次运行", "runDiagnosticsTitle": "运行诊断", @@ -942,7 +972,6 @@ "signalQuality": "信号质量", "signalQualitySubtitle": "按频段划分的 WiFi 信号质量", "signalStrengthDbm": "{value} dBm", - "singlePortWithCount": "单端口({count})", "singleRouterSetupNoBackhaul": "单路由器设置——无回程", "sizeBytes": "大小:{size} 字节({mib} MiB)", "skipped": "已跳过", @@ -1003,15 +1032,10 @@ "trend": "趋势", "trends": "趋势", "triggerPorts": "触发端口", - "triggeringWithCount": "触发({count})", "txPower": "发射功率", "type": "类型", "typeAMessage": "输入消息…", "unableToGatherDeviceInfo": "无法收集设备信息", - "unableToLoadApps": "无法加载应用", - "unableToLoadDiagnostics": "无法加载诊断", - "unableToLoadSpeedTest": "无法加载速度测试", - "unableToLoadTopology": "无法加载拓扑", "unchangedLabel": "(未更改)", "unknownWidget": "未知小组件:{id}", "unnamed": "(未命名)", @@ -1099,5 +1123,12 @@ "targetChain": "目标链", "synchronized": "已同步", "noPortForwardingRulesConfigured": "未配置端口转发规则", - "nItems": "{count, plural, other{{count} 个项目}}" + "nItems": "{count, plural, other{{count} 个项目}}", + "bridgeReconnectHint": "保存后,请改用 {url} 重新连接到您的路由器。", + "bridgeRedirectTitle": "重新连接到您的路由器", + "bridgeRedirectMessage": "您的路由器现在是透明网桥,不再分配本地 IP 地址。请改用 {url} 重新连接。", + "bridgeRedirectButton": "前往路由器", + "lanIpRedirectTitle": "重新连接到您的路由器", + "lanIpRedirectMessage": "您的路由器 IP 地址已更改。请通过 {url} 重新连接。您可能需要稍等片刻,等待设备获取新地址。", + "lanIpRedirectButton": "前往路由器" } diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 7f051aec8..05a6455ee 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -126,6 +126,8 @@ "invalidDestinationMacAddress": "目標MAC地址無效", "invalidDns": "DNS 不正確", "invalidGatewayIpAddress": "網關IP地址無效", + "gatewayMustBeWithinLanSubnet": "閘道必須在 LAN 子網路內", + "gatewayMustBeOutsideLanSubnet": "閘道必須在 LAN 子網路外", "invalidInput": "輸入無效", "invalidIpAddress": "IP 位址不正確", "invalidMACAddress": "MAC 位址不正確。", @@ -134,6 +136,7 @@ "ipv4": "IPv4", "ipv6": "IPv6", "ipv6Address": "IPv6 位址", + "ipv6ScopeLinkLocal": "連結本地", "keepAlive": "保持活躍", "l2tpPassthrough": "L2TP 穿透", "labelInterface": "介面", @@ -240,9 +243,11 @@ "prefix": "前綴", "prefixLength": "前綴長度", "print": "列印", + "privateMac": "私人", "privacyAndSecurity": "隱私與安全", "processing": "處理中...", "protocol": "通訊協定", + "publicMac": "公開", "quickSetup": "快速設定", "applyToAllBandsDesc": "同時將相同的 WiFi 設定應用到所有頻段", "rebootChildTitle": "重新啟動節點及其所有子節點", @@ -381,7 +386,7 @@ "addDeviceManually": "手動新增裝置", "addPortForwarding": "新增連接埠轉送", "addPortRangeForwarding": "新增連接埠範圍轉送", - "addPortTriggering": "新增連接埠觸發", + "addPortTriggering": "新增連接埠範圍觸發", "addedWidgetNamed": "已新增 {name}", "adding": "正在新增…", "additionalFiltersOnlineOnly": "其他篩選條件僅適用於連線中的裝置。", @@ -435,6 +440,17 @@ "changeNetworkSettingsTitle": "變更網路設定?", "changeRouterPassword": "變更路由器密碼", "channelNumber": "通道編號", + "channelAutoRecommended": "Auto (recommended)", + "channelCurrentlyUsing": "Currently using: {channel}", + "@channelCurrentlyUsing": { + "placeholders": { + "channel": { + "type": "int" + } + } + }, + "channelDfsSuffix": "· DFS", + "channelNoManualOptions": "No manual channels available for this band.", "channels": "通道", "checkConnectivityAndSpeed": "檢查連線能力與速度", "checkForUpdates": "檢查更新", @@ -463,6 +479,8 @@ "confirmationRequired": "需要確認", "confirmingNewFirmware": "正在確認新韌體是否正在執行…", "connect": "連線", + "duplicateMacAddress": "此 MAC 位址已被保留", + "duplicateIpAddress": "此 IP 位址已被保留", "connectedDevices": "已連線的裝置", "connecting": "正在連線...", "connectingToRouter": "正在連線至路由器...", @@ -547,6 +565,9 @@ "diagnosticsRecWeakWifiTitle": "WiFi 訊號微弱", "disable": "停用", "disableInstantPrivacyDesc": "所有裝置都將能自由連線至您的網路。", + "privateMacWarningTitle": "部分裝置使用私密 Wi-Fi 位址", + "privateMacWarningDesc": "以下裝置使用私密(隨機)Wi-Fi 位址。由於該位址會隨時間變更,輪替後這些裝置可能會被封鎖——即使是您目前正在使用的裝置。為維持連線,請在啟用即時隱私前,關閉這些裝置的「私密 Wi-Fi 位址」。", + "privateMacLabel": "私密", "disableInstantPrivacyTitle": "停用即時隱私?", "discards": "捨棄", "distribution": "分布", @@ -573,7 +594,7 @@ "dynamicFrequencySelection": "動態頻率選擇 (DFS)", "editPortForwarding": "編輯連接埠轉送", "editPortRangeForwarding": "編輯連接埠範圍轉送", - "editPortTriggering": "編輯連接埠觸發", + "editPortTriggering": "編輯連接埠範圍觸發", "editRule": "編輯規則", "editStaticRoute": "編輯靜態路由", "editTimeSettings": "編輯時間設定", @@ -597,7 +618,6 @@ "errorInvalidCredentials": "使用者名稱或密碼不正確。", "errorInvalidInput": "輸入的值無效。請檢查後再試一次。", "errorInvalidSessionToken": "您的工作階段無效。請重新登入。", - "errorLoadingSpeedTest": "載入速度測試時發生錯誤", "errorNetwork": "網路錯誤。請檢查您的連線後再試一次。", "errorNotAuthenticated": "您尚未登入。請登入後再試一次。", "errorResourceNotFound": "在路由器上找不到要求的設定。", @@ -763,6 +783,9 @@ "noAdvancedWifiSettings": "此裝置沒有可用的進階 WiFi 設定。", "noAnswersReturned": "沒有傳回任何答案。", "noAppsInstalled": "此路由器上未安裝任何 App", + "unableToLoadApps": "無法載入 App", + "unableToLoadHealthData": "無法載入健康狀態資料", + "unableToLoadTopology": "無法載入拓撲", "noClients": "沒有用戶端", "noDeviceActivityRecorded": "未記錄任何裝置活動", "noDevicesCurrentlyConnected": "目前沒有裝置連線。", @@ -780,7 +803,7 @@ "noLogFilesAvailable": "此路由器上沒有可用的記錄檔", "noPortMappingsConfigured": "未設定任何連接埠對應", "noPortRangeRules": "未設定任何連接埠範圍轉送規則", - "noPortTriggeringRules": "未設定任何連接埠觸發規則", + "noPortTriggeringRules": "未設定連接埠範圍觸發規則", "noPresetSelected": "未選擇任何預設組合", "noReservationIpMayChange": "沒有保留。IP 在重新連線時可能會變更。", "noSinglePortRules": "未設定任何單一連接埠轉送規則", @@ -836,9 +859,8 @@ "portMapping": "連接埠對應", "portMappingSubtitle": "連接埠轉送規則與 DMZ 設定", "portMustBe1To65535": "連接埠必須介於 1-65535", - "portRangeWithCount": "連接埠範圍 ({count})", "portRules": "連接埠規則", - "portTriggering": "連接埠觸發", + "portTriggering": "連接埠範圍觸發", "ports": "連接埠", "potentialIssues": "潛在問題", "pppStatus": "PPP 狀態", @@ -882,6 +904,12 @@ "renew": "更新", "requiredLabel": "(必填)", "reservationReleased": "已釋放保留", + "reservationAdded": "已新增保留", + "reservationDeleted": "已刪除保留", + "reconnectedToRouter": "已重新連線到路由器", + "timeSettingsSaved": "已儲存時間設定", + "ruleAdded": "已新增規則", + "channelUpdated": "已更新通道", "reservations": "保留", "reserveIpAddress": "保留 IP 位址", "reserved": "已保留", @@ -908,6 +936,8 @@ "routerRebootComplete": "路由器重新啟動完成", "routerWritingImage": "路由器正在寫入新映像檔。請勿關閉電源。", "rtColumn": "RT", + "unableToLoadDiagnostics": "無法載入診斷", + "unableToLoadSpeedTest": "無法載入速度測試", "rules": "規則", "runAgain": "再次執行", "runDiagnosticsTitle": "執行診斷", @@ -942,7 +972,6 @@ "signalQuality": "訊號品質", "signalQualitySubtitle": "依頻段的 WiFi 訊號品質", "signalStrengthDbm": "{value} dBm", - "singlePortWithCount": "單一連接埠 ({count})", "singleRouterSetupNoBackhaul": "單一路由器設定 — 沒有回程", "sizeBytes": "大小:{size} bytes ({mib} MiB)", "skipped": "已略過", @@ -1003,15 +1032,10 @@ "trend": "趨勢", "trends": "趨勢", "triggerPorts": "觸發連接埠", - "triggeringWithCount": "觸發 ({count})", "txPower": "Tx 功率", "type": "類型", "typeAMessage": "輸入訊息...", "unableToGatherDeviceInfo": "無法收集裝置資訊", - "unableToLoadApps": "無法載入 App", - "unableToLoadDiagnostics": "無法載入診斷", - "unableToLoadSpeedTest": "無法載入速度測試", - "unableToLoadTopology": "無法載入拓撲", "unchangedLabel": "(未變更)", "unknownWidget": "未知的小工具:{id}", "unnamed": "(未命名)", @@ -1099,5 +1123,12 @@ "targetChain": "目標鏈", "synchronized": "已同步", "noPortForwardingRulesConfigured": "未設定連接埠轉發規則", - "nItems": "{count, plural, other{{count} 個項目}}" + "nItems": "{count, plural, other{{count} 個項目}}", + "bridgeReconnectHint": "儲存後,請改用 {url} 重新連線到您的路由器。", + "bridgeRedirectTitle": "重新連線到您的路由器", + "bridgeRedirectMessage": "您的路由器現在是透明橋接器,不再配發本機 IP 位址。請改用 {url} 重新連線。", + "bridgeRedirectButton": "前往路由器", + "lanIpRedirectTitle": "重新連線到您的路由器", + "lanIpRedirectMessage": "您的路由器 IP 位址已變更。請透過 {url} 重新連線。您可能需要稍候片刻,等待裝置取得新位址。", + "lanIpRedirectButton": "前往路由器" } diff --git a/lib/localization/fallback_font_resolver.dart b/lib/localization/fallback_font_resolver.dart new file mode 100644 index 000000000..fe69eff3b --- /dev/null +++ b/lib/localization/fallback_font_resolver.dart @@ -0,0 +1,92 @@ +import 'package:flutter/widgets.dart' show Locale; +import 'package:ui_kit_library/ui_kit.dart' show LocaleFallbackFont; + +/// Maps a locale to the bundled fallback font family for scripts the primary +/// font (NeueHaasGrotTextRound) doesn't cover: CJK, Greek, Cyrillic, +/// Vietnamese, Thai, Arabic. +/// +/// These families are declared under `fonts:` in pubspec.yaml (eager-loaded and +/// registered with the engine before the first frame; assets in +/// `assets/fonts/fallback/`) as `packages/ui_kit_library/`. The single +/// source of truth for the locale→family mapping lives HERE. +/// +/// **Two consumption forms — this matters:** +/// - ui_kit's [LocaleFallbackFont] (used by AppText) needs the BARE family name. +/// AppText's base TextStyle sets `package: ui_kit_library`, so `copyWith` +/// auto-prefixes fallback entries with `packages/ui_kit_library/`. Passing a +/// pre-prefixed name there produces a DOUBLE prefix that matches nothing. +/// - app.dart's ThemeData.textTheme fallback (for raw `Text`) does NOT go +/// through that base style, so it needs the PREFIXED name to match the +/// pubspec `fonts:` family. +/// +/// Returns null for locales fully covered by the primary Latin font +/// (en/fr/de/es/pt/nordic/pl/tr …). +class FallbackFontResolver { + FallbackFontResolver._(); + + static const _prefix = 'packages/ui_kit_library'; + + /// Injects the BARE-name resolver into ui_kit. Call once at startup. + static void install() { + LocaleFallbackFont.resolver = _bareFallbackFor; + } + + /// Bare family name (no package prefix) for [locale] — for ui_kit injection. + static String? bareFamilyForLocale({ + required String languageCode, + String? countryCode, + String? scriptCode, + }) { + switch (languageCode.toLowerCase()) { + case 'ja': + return 'NotoSansJP'; + case 'ko': + return 'NotoSansKR'; + case 'zh': + final region = countryCode?.toUpperCase(); + final script = scriptCode?.toLowerCase(); + final isTraditional = script == 'hant' || + region == 'TW' || + region == 'HK' || + region == 'MO'; + if (isTraditional) { + return (region == 'HK' || region == 'MO') + ? 'NotoSansHK' + : 'NotoSansTC'; + } + return 'NotoSansSC'; + case 'th': + return 'NotoSansThai'; + case 'ar': + return 'NotoSansArabic'; + case 'el': // Greek + case 'ru': // Cyrillic + case 'vi': // Vietnamese extended Latin + return 'NotoSansLatinExt'; + default: + return null; + } + } + + static List? _bareFallbackFor(Locale? locale) { + if (locale == null) return null; + final fam = bareFamilyForLocale( + languageCode: locale.languageCode, + countryCode: locale.countryCode, + scriptCode: locale.scriptCode, + ); + return fam == null ? null : [fam]; + } + + /// Package-prefixed fallback list for [locale] — for app.dart's + /// ThemeData.textTheme (raw `Text`, which doesn't get ui_kit's auto-prefix). + static List? prefixedFallbackFor(Locale? locale) { + if (locale == null) return null; + final fam = bareFamilyForLocale( + languageCode: locale.languageCode, + countryCode: locale.countryCode, + scriptCode: locale.scriptCode, + ); + return fam == null ? null : ['$_prefix/$fam']; + } +} diff --git a/lib/main.dart b/lib/main.dart index 057cb47c0..63da737b2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,10 +10,12 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:privacy_gui/config/global_config.dart'; import 'package:privacy_gui/constants/_constants.dart'; import 'package:privacy_gui/app.dart'; +import 'package:privacy_gui/localization/fallback_font_resolver.dart'; import 'package:privacy_gui/di.dart'; import 'package:privacy_gui/providers/logger_observer.dart'; import 'package:privacy_gui/core/utils/logger.dart'; +import 'package:privacy_gui/core/utils/state_log_observer.dart'; import 'package:privacy_gui/core/utils/oui_lookup.dart'; import 'package:privacy_gui/core/utils/storage.dart'; import 'package:privacy_gui/core/usp/services/usp_bridge_client.dart'; @@ -79,6 +81,10 @@ void main() async { // GetIt - Register services and default theme data dependencySetup(); + // Inject the app's locale→fallback-font mapping into ui_kit so AppText + // applies the bundled CJK/non-Latin subsets per locale. + FallbackFontResolver.install(); + runApp(app()); } @@ -134,6 +140,7 @@ Widget app() { return ProviderScope( observers: [ ProviderLogger(), + StateLogObserver(), ], child: const LinksysApp(), ); diff --git a/lib/page/_shared/components/detail_widgets.dart b/lib/page/_shared/components/detail_widgets.dart index e104b3d2b..84381f66d 100644 --- a/lib/page/_shared/components/detail_widgets.dart +++ b/lib/page/_shared/components/detail_widgets.dart @@ -183,11 +183,16 @@ class DetailCopyableTile extends StatelessWidget { final String label; final String value; + /// Optional widget that replaces the default leading [Icon] (e.g. an + /// [Ipv6ScopeBadge] carrying its own tooltip/semantics). + final Widget? leading; + const DetailCopyableTile({ super.key, required this.icon, required this.label, required this.value, + this.leading, }); @override @@ -196,7 +201,7 @@ class DetailCopyableTile extends StatelessWidget { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(icon, size: 16, color: colorScheme.onSurfaceVariant), + leading ?? Icon(icon, size: 16, color: colorScheme.onSurfaceVariant), AppGap.sm(), Expanded( child: Column( diff --git a/lib/page/_shared/components/layout_blocks/list_blocks.dart b/lib/page/_shared/components/layout_blocks/list_blocks.dart index bf7e020c1..f27a2879f 100644 --- a/lib/page/_shared/components/layout_blocks/list_blocks.dart +++ b/lib/page/_shared/components/layout_blocks/list_blocks.dart @@ -158,14 +158,46 @@ class InfoGridItem { final bool fullWidth; final bool copyable; + /// Optional widget rendered next to the label (e.g. an [Ipv6ScopeBadge]). + final Widget? labelTrailing; + const InfoGridItem({ required this.label, required this.value, this.fullWidth = false, this.copyable = false, + this.labelTrailing, }); } +/// Icon-only marker for an IPv6 link-local (`fe80::/10`) address. +/// +/// Link-local addresses are only valid on a single link and are not routable. +/// Wherever an IPv6 address is surfaced (dashboard cards and the device/node +/// detail views), a link-local address is shown but tagged with this compact +/// icon (`public_off` — mirroring the routable `public` icon used for WAN +/// addresses) rather than hidden. The tooltip provides discoverability and +/// doubles as the semantic label for screen readers, since the icon alone +/// carries no text. +class Ipv6ScopeBadge extends StatelessWidget { + final double? size; + + const Ipv6ScopeBadge({super.key, this.size}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Tooltip( + message: loc(context).ipv6ScopeLinkLocal, + child: Icon( + Icons.public_off, + size: size ?? BlockConstants.iconSm, + color: colorScheme.onSurfaceVariant, + ), + ); + } +} + class _InfoGridTile extends StatelessWidget { final InfoGridItem item; @@ -179,10 +211,23 @@ class _InfoGridTile extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - AppText.labelSmall( - item.label.toUpperCase(), - color: colorScheme.onSurfaceVariant, - ), + if (item.labelTrailing != null) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + AppText.labelSmall( + item.label.toUpperCase(), + color: colorScheme.onSurfaceVariant, + ), + AppGap.xs(), + item.labelTrailing!, + ], + ) + else + AppText.labelSmall( + item.label.toUpperCase(), + color: colorScheme.onSurfaceVariant, + ), AppGap.xs(), item.copyable ? _CopyableText(text: item.value) diff --git a/lib/page/_shared/components/layout_blocks/row_blocks.dart b/lib/page/_shared/components/layout_blocks/row_blocks.dart index 1c4578aaa..ae6710ca2 100644 --- a/lib/page/_shared/components/layout_blocks/row_blocks.dart +++ b/lib/page/_shared/components/layout_blocks/row_blocks.dart @@ -125,3 +125,220 @@ class NetworkBadgeWidget extends StatelessWidget { ); } } + +// ============================================================================= +// ToggleRow - Row with leading switch toggle +// ============================================================================= + +/// Toggle row block with leading switch, title, subtitle, and optional trailing. +/// +/// Uses [AppListTile] from UI Kit for consistent styling. +/// Use for DHCP reservations, port forwarding rules, etc. +/// +/// When [isLoading] is true, displays a spinner in place of the switch. +class ToggleRow extends StatelessWidget { + final bool value; + final ValueChanged? onChanged; + final String title; + final String? subtitle; + final Widget? trailing; + final VoidCallback? onTap; + final bool isLoading; + + const ToggleRow({ + super.key, + required this.value, + this.onChanged, + required this.title, + this.subtitle, + this.trailing, + this.onTap, + this.isLoading = false, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return AppListTile( + backgroundColor: colorScheme.surfaceContainerHighest + .withValues(alpha: BlockConstants.backgroundAlpha), + leading: SizedBox( + width: 44, + child: Center( + child: isLoading + ? SizedBox.square( + dimension: 26, + child: AppLoader(strokeWidth: 2), + ) + : AppSwitch( + value: value, + onChanged: onChanged, + scale: 0.8, + ), + ), + ), + title: AppText.bodyMedium( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: subtitle != null + ? AppText.bodySmall( + subtitle!, + color: colorScheme.onSurfaceVariant, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ) + : null, + trailing: trailing, + onTap: onTap, + ); + } +} + +// ============================================================================= +// NetworkRow - WiFi network row with badges and switch +// ============================================================================= + +/// Network row block for WiFi networks with band badges, client count, and toggle. +/// +/// Uses [AppListTile] from UI Kit for consistent styling. +/// +/// When [isLoading] is true, displays a spinner in place of the switch. +class NetworkRow extends StatelessWidget { + final String ssidName; + final List bands; + final bool isGuest; + final bool isEnabled; + final int clientCount; + final ValueChanged? onChanged; + final VoidCallback? onShareTap; + final bool isLoading; + + const NetworkRow({ + super.key, + required this.ssidName, + required this.bands, + this.isGuest = false, + required this.isEnabled, + required this.clientCount, + this.onChanged, + this.onShareTap, + this.isLoading = false, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Opacity( + opacity: isEnabled ? 1.0 : BlockConstants.disabledAlpha, + child: AppListTile( + backgroundColor: colorScheme.surfaceContainerHighest + .withValues(alpha: BlockConstants.backgroundAlpha), + title: Row( + children: [ + Flexible( + child: AppText.bodyLarge( + ssidName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (isGuest) ...[ + AppGap.sm(), + _GuestBadge(), + ], + ], + ), + subtitle: Row( + children: [ + ...bands.map((band) => Padding( + padding: const EdgeInsets.only(right: AppSpacing.xs), + child: NetworkBadgeWidget(badge: NetworkBadge.fromBand(band)), + )), + AppGap.sm(), + Icon( + Icons.devices, + size: 14, + color: colorScheme.onSurfaceVariant, + ), + AppGap.xxs(), + AppText.labelSmall( + '$clientCount', + color: colorScheme.onSurfaceVariant, + ), + ], + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (!isLoading && isEnabled && onShareTap != null) ...[ + _ShareButton(onTap: onShareTap!), + AppGap.sm(), + ], + isLoading + ? SizedBox( + width: 52, + height: 32, + child: Center( + child: SizedBox.square( + dimension: 24, + child: AppLoader(strokeWidth: 2), + ), + ), + ) + : AppSwitch( + value: isEnabled, + onChanged: onChanged, + ), + ], + ), + ), + ); + } +} + +class _GuestBadge extends StatelessWidget { + @override + Widget build(BuildContext context) { + return AppBadge( + label: 'Guest', + color: Theme.of(context).colorScheme.secondary, + ); + } +} + +class _ShareButton extends StatelessWidget { + final VoidCallback onTap; + + const _ShareButton({required this.onTap}); + + @override + Widget build(BuildContext context) { + return AppIconButton( + icon: AppIcon.font(Icons.qr_code_2, size: 24), + onTap: onTap, + ); + } +} + +// ============================================================================= +// ProtocolBadge - Protocol indicator badge (TCP/UDP/Both) +// ============================================================================= + +/// Protocol badge for port forwarding/triggering rules. +class ProtocolBadge extends StatelessWidget { + final String protocol; + + const ProtocolBadge({super.key, required this.protocol}); + + @override + Widget build(BuildContext context) { + return AppBadge( + label: protocol, + color: Theme.of(context).colorScheme.primary, + ); + } +} diff --git a/lib/page/_shared/components/usp_mutation_helper.dart b/lib/page/_shared/components/usp_mutation_helper.dart index e68370815..c68d69f24 100644 --- a/lib/page/_shared/components/usp_mutation_helper.dart +++ b/lib/page/_shared/components/usp_mutation_helper.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacy_gui/components/localizations/service_error_localizations.dart'; import 'package:privacy_gui/components/shortcuts/snack_bar.dart'; /// Tracks which card is currently being mutated (for loading overlay). @@ -9,7 +10,12 @@ final uspMutationLoadingProvider = StateProvider((ref) => null); /// Executes a USP mutation with loading state management and error handling. /// /// Sets [uspMutationLoadingProvider] to [loadingKey] before the mutation, -/// resets it to null afterward, and shows snackbar on success/failure. +/// resets it to null afterward, and shows a snackbar on success/failure. +/// +/// On failure the caught error is localized via [localizeServiceError] (the +/// same central mapper used by feature views) — so callers do NOT need to +/// localize themselves; just pass the mutation. Note [successMessage] is shown +/// as-is, so callers should pass an already-localized string. Future performUspMutation( BuildContext context, WidgetRef ref, { @@ -25,7 +31,7 @@ Future performUspMutation( } } catch (e) { if (context.mounted) { - showFailedSnackBar(context, 'Error: $e'); + showFailedSnackBar(context, localizeServiceError(context, e)); } } finally { ref.read(uspMutationLoadingProvider.notifier).state = null; diff --git a/lib/page/_shared/components/wifi_ui.dart b/lib/page/_shared/components/wifi_ui.dart index a106294c7..7db7f0337 100644 --- a/lib/page/_shared/components/wifi_ui.dart +++ b/lib/page/_shared/components/wifi_ui.dart @@ -105,6 +105,11 @@ String wifiDisplayValue(BuildContext context, String value) { return loc(context).none; case 'Mixed': return loc(context).mixed; + case 'OWE': + // Firmware reports Enhanced Open as the TR-181 token 'OWE'. Show the + // Wi-Fi standard label instead of the raw token; like WPA2/WPA3-Personal + // it is a technical term and is not localized. + return 'Enhanced Open'; default: return value; } diff --git a/lib/page/_shared/extensions/device_ui_extensions.dart b/lib/page/_shared/extensions/device_ui_extensions.dart deleted file mode 100644 index d2f3ca0a7..000000000 --- a/lib/page/_shared/extensions/device_ui_extensions.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; - -/// Extension to get [IconData] for [DeviceConnectionType]. -extension DeviceConnectionTypeExt on DeviceConnectionType { - IconData get icon => switch (this) { - DeviceConnectionType.wifi => Icons.wifi, - DeviceConnectionType.wired => Icons.settings_ethernet, - }; -} diff --git a/lib/page/_shared/models/backhaul_info.dart b/lib/page/_shared/models/backhaul_info.dart new file mode 100644 index 000000000..635c89598 --- /dev/null +++ b/lib/page/_shared/models/backhaul_info.dart @@ -0,0 +1,96 @@ +import 'package:equatable/equatable.dart'; +import 'package:privacy_gui/framework/diagnostic_loggable.dart'; + +/// Backhaul connection info for slave mesh nodes. +/// +/// Describes how a slave node connects to its parent (WiFi or Ethernet). +class BackhaulInfo with EquatableMixin, DiagnosticNamed { + /// Media type description, e.g., "IEEE 802.11ax", "Ethernet". + final String mediaType; + + /// Link type: "Wi-Fi" or "Ethernet". + final String? linkType; + + /// PHY rate in Mbps. + final int phyRate; + + /// Signal strength in dBm (RSSI). Null for Ethernet backhaul. + final int? signalStrength; + + /// Uplink data rate in kbps. + final int? uplinkRate; + + /// Downlink data rate in kbps. + final int? downlinkRate; + + /// Parent node's device ID (MAC). + final String? parentNodeId; + + /// Parent node's BSSID the slave connects to. + final String? parentBssid; + + /// Last contact time in ISO 8601 format. + final String? lastContactTime; + + /// Raw AL ID from DataElements (parent node MAC). + final String? backhaulAlId; + + /// Backhaul interface MAC address. + final String? backhaulMacAddress; + + const BackhaulInfo({ + required this.mediaType, + this.linkType, + this.phyRate = 0, + this.signalStrength, + this.uplinkRate, + this.downlinkRate, + this.parentNodeId, + this.parentBssid, + this.lastContactTime, + this.backhaulAlId, + this.backhaulMacAddress, + }); + + /// Whether the backhaul is Ethernet (wired). + bool get isEthernet => linkType == 'Ethernet'; + + /// Whether the backhaul is WiFi (wireless). + bool get isWifi => !isEthernet; + + /// Whether backhaul info is available. + bool get hasInfo => mediaType.isNotEmpty; + + @override + List get props => [ + mediaType, + linkType, + phyRate, + signalStrength, + uplinkRate, + downlinkRate, + parentNodeId, + parentBssid, + lastContactTime, + backhaulAlId, + backhaulMacAddress, + ]; + + @override + String get diagnosticName => 'BackhaulInfo'; + + @override + Map get namedProps => { + 'mediaType': mediaType, + 'linkType': linkType, + 'phyRate': phyRate, + 'signalStrength': signalStrength, + 'uplinkRate': uplinkRate, + 'downlinkRate': downlinkRate, + 'parentNodeId': parentNodeId, + 'parentBssid': parentBssid, + 'lastContactTime': lastContactTime, + 'backhaulAlId': backhaulAlId, + 'backhaulMacAddress': backhaulMacAddress, + }; +} diff --git a/lib/page/_shared/models/client_connection_detail.dart b/lib/page/_shared/models/client_connection_detail.dart index d5130e8b8..fd253eb13 100644 --- a/lib/page/_shared/models/client_connection_detail.dart +++ b/lib/page/_shared/models/client_connection_detail.dart @@ -1,7 +1,19 @@ +import 'package:equatable/equatable.dart'; +import 'package:privacy_gui/framework/diagnostic_loggable.dart'; + /// Connection detail for a WiFi client: band + SSID name. -class ClientConnectionDetail { +class ClientConnectionDetail extends Equatable with DiagnosticLoggable { final String band; // "2.4GHz", "5GHz", "6GHz", or "" final String ssidName; // The network name const ClientConnectionDetail({required this.band, required this.ssidName}); + + @override + String get diagnosticName => 'ClientConnectionDetail'; + + @override + Map get namedProps => { + 'band': band, + 'ssidName': ssidName, + }; } diff --git a/lib/page/_shared/models/client_device.dart b/lib/page/_shared/models/client_device.dart new file mode 100644 index 000000000..f7c46da13 --- /dev/null +++ b/lib/page/_shared/models/client_device.dart @@ -0,0 +1,339 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter/material.dart'; +import 'package:privacy_gui/framework/diagnostic_loggable.dart'; +import 'package:privacy_gui/page/_shared/models/network_entity.dart'; +import 'package:privacy_gui/page/_shared/models/wifi_connection_info.dart'; + +/// Connection type for client devices. +enum ConnectionType { wifi, wired } + +/// Extension for connection type icons. +extension ConnectionTypeExt on ConnectionType { + IconData get icon => switch (this) { + ConnectionType.wifi => Icons.wifi, + ConnectionType.wired => Icons.settings_ethernet, + }; +} + +/// Network interface info for multi-interface devices. +/// +/// When a device connects via multiple interfaces (e.g., WiFi + Ethernet), +/// the primary interface is stored in [ClientDevice] fields and additional +/// interfaces are stored in [ClientDevice.additionalInterfaces]. +class ClientInterfaceInfo with EquatableMixin, DiagnosticNamed { + /// MAC address of this interface. + final String mac; + + /// IP address of this interface. + final String ip; + + /// Connection type (WiFi or wired). + final ConnectionType connectionType; + + /// Whether this interface is currently active. + final bool isActive; + + /// Layer1Interface path (for port correlation). + final String layer1Interface; + + /// WiFi connection details (null if wired). + final WifiConnectionInfo? wifi; + + const ClientInterfaceInfo({ + required this.mac, + required this.ip, + required this.connectionType, + required this.isActive, + this.layer1Interface = '', + this.wifi, + }); + + /// Whether this is a WiFi interface. + bool get isWifi => connectionType == ConnectionType.wifi; + + /// Signal strength in dBm (from WiFi info). + int? get signalStrength => wifi?.signalStrength; + + /// WiFi band (2.4GHz, 5GHz, 6GHz). + String? get band => wifi?.band; + + /// SSID name. + String? get ssidName => wifi?.ssidName; + + @override + List get props => [ + mac, + ip, + connectionType, + isActive, + layer1Interface, + wifi, + ]; + + @override + String get diagnosticName => 'ClientInterfaceInfo'; + + @override + Map get namedProps => { + 'mac': mac, + 'ip': ip, + 'connectionType': connectionType, + 'isActive': isActive, + 'layer1Interface': layer1Interface, + 'wifi': wifi, + }; +} + +/// Client device connected to the mesh network. +/// +/// Represents end-user devices (phones, laptops, etc.) that connect to +/// mesh nodes. Implements [NetworkEntity] for unified identity handling. +final class ClientDevice extends NetworkEntity with DiagnosticNamed { + // ─── Identity ─── + /// MAC address (uppercase, normalized). + final String mac; + + /// Hostname from Hosts table. + final String hostName; + + /// User-friendly name (editable). + final String? friendlyName; + + // ─── Status ─── + /// Whether the device is currently online. + final bool isActive; + + // ─── Network ─── + /// IPv4 address. + final String ip; + + /// IPv6 addresses. + @override + final List ipv6Addresses; + + /// Layer1Interface path (for port correlation). + final String layer1Interface; + + // ─── Connection ─── + /// Connection type (WiFi or wired). + final ConnectionType connectionType; + + /// WiFi connection details (null if wired). + final WifiConnectionInfo? wifi; + + /// Parent mesh node ID this device is connected to. + final String? parentNodeId; + + /// Parent mesh node display name (for UI). + final String? parentNodeName; + + // ─── Device Info ─── + /// Device manufacturer. + final String? manufacturer; + + /// Device model name. + final String? modelName; + + /// Device operating system. + final String? operatingSystem; + + /// Hosts DeviceID (UUID, for DataElements matching). + final String? hostsDeviceId; + + // ─── Multi-interface ─── + /// Additional network interfaces for this device. + final List additionalInterfaces; + + ClientDevice({ + required this.mac, + required this.hostName, + this.friendlyName, + required this.isActive, + required this.ip, + this.ipv6Addresses = const [], + this.layer1Interface = '', + required this.connectionType, + this.wifi, + this.parentNodeId, + this.parentNodeName, + this.manufacturer, + this.modelName, + this.operatingSystem, + this.hostsDeviceId, + this.additionalInterfaces = const [], + }); + + // ─── NetworkEntity implementation ─── + + @override + String get id => mac; + + @override + String get displayName { + if (friendlyName != null && friendlyName!.isNotEmpty) return friendlyName!; + if (hostName.isNotEmpty) return hostName; + return mac; + } + + @override + bool get isOnline => isActive; + + @override + String? get ipAddress => ip.isNotEmpty ? ip : null; + + // ─── Computed getters ─── + + /// Whether this is a WiFi device. + bool get isWifi => connectionType == ConnectionType.wifi; + + /// Signal strength in dBm (from WiFi info). + int? get signalStrength => wifi?.signalStrength; + + /// Signal quality (0.0–1.0). + double get signalQuality => wifi?.signalQuality ?? 0; + + /// Signal level (0–3). + int get signalLevel => wifi?.signalLevel ?? 0; + + /// WiFi band (2.4GHz, 5GHz, 6GHz). + String? get band => wifi?.band; + + /// SSID name. + String? get ssidName => wifi?.ssidName; + + /// Downlink rate in kbps. + int? get downlinkRate => wifi?.downlinkRate; + + /// Uplink rate in kbps. + int? get uplinkRate => wifi?.uplinkRate; + + /// Whether WiFi details should be displayed. + bool get hasWifiData => wifi?.hasData ?? false; + + /// Whether this device has multiple network interfaces. + bool get hasMultipleInterfaces => additionalInterfaces.isNotEmpty; + + /// All MAC addresses for this device (primary + additional). + List get allMacAddresses => + [mac, ...additionalInterfaces.map((i) => i.mac)]; + + /// Total number of interfaces. + int get interfaceCount => 1 + additionalInterfaces.length; + + /// Whether any interface is active. + bool get hasAnyActiveInterface => + isActive || additionalInterfaces.any((i) => i.isActive); + + /// Total throughput in kbps (uplink + downlink). + int get totalThroughput => (downlinkRate ?? 0) + (uplinkRate ?? 0); + + /// Whether to display signal information (WiFi + online + has signal). + bool get hasSignalDisplay => isWifi && isActive && signalStrength != null; + + /// Whether WiFi signal details should be shown in detail view. + bool get shouldShowWifiDetails => + isWifi && isActive && (hasWifiData || signalStrength != null); + + /// Whether this device is interactive (can be tapped for detail). + bool get isInteractive => isActive; + + /// Display opacity for list items (dimmed when offline). + double get displayOpacity => isActive ? 1.0 : 0.5; + + /// Creates a copy with optional field overrides. + ClientDevice copyWith({ + String? mac, + String? hostName, + String? friendlyName, + bool? isActive, + String? ip, + List? ipv6Addresses, + String? layer1Interface, + ConnectionType? connectionType, + WifiConnectionInfo? wifi, + String? parentNodeId, + String? parentNodeName, + String? manufacturer, + String? modelName, + String? operatingSystem, + String? hostsDeviceId, + List? additionalInterfaces, + }) { + return ClientDevice( + mac: mac ?? this.mac, + hostName: hostName ?? this.hostName, + friendlyName: friendlyName ?? this.friendlyName, + isActive: isActive ?? this.isActive, + ip: ip ?? this.ip, + ipv6Addresses: ipv6Addresses ?? this.ipv6Addresses, + layer1Interface: layer1Interface ?? this.layer1Interface, + connectionType: connectionType ?? this.connectionType, + wifi: wifi ?? this.wifi, + parentNodeId: parentNodeId ?? this.parentNodeId, + parentNodeName: parentNodeName ?? this.parentNodeName, + manufacturer: manufacturer ?? this.manufacturer, + modelName: modelName ?? this.modelName, + operatingSystem: operatingSystem ?? this.operatingSystem, + hostsDeviceId: hostsDeviceId ?? this.hostsDeviceId, + additionalInterfaces: additionalInterfaces ?? this.additionalInterfaces, + ); + } + + @override + List get props => [ + mac, + hostName, + friendlyName, + isActive, + ip, + ipv6Addresses, + layer1Interface, + connectionType, + wifi, + parentNodeId, + parentNodeName, + manufacturer, + modelName, + operatingSystem, + hostsDeviceId, + additionalInterfaces, + ]; + + @override + String get diagnosticName => 'ClientDevice'; + + @override + Map get namedProps => { + 'mac': mac, + 'hostName': hostName, + 'friendlyName': friendlyName, + 'isActive': isActive, + 'ip': ip, + 'ipv6Addresses': ipv6Addresses, + 'layer1Interface': layer1Interface, + 'connectionType': connectionType, + 'wifi': wifi, + 'parentNodeId': parentNodeId, + 'parentNodeName': parentNodeName, + 'manufacturer': manufacturer, + 'modelName': modelName, + 'operatingSystem': operatingSystem, + 'hostsDeviceId': hostsDeviceId, + 'additionalInterfaces': additionalInterfaces, + }; +} + +/// Extension methods for List. +extension ClientDeviceListExt on List { + /// Returns only online devices. + List get online => where((d) => d.isOnline).toList(); + + /// Returns only offline devices. + List get offline => where((d) => !d.isOnline).toList(); + + /// Returns only WiFi devices. + List get wifiDevices => where((d) => d.isWifi).toList(); + + /// Returns only wired devices. + List get wiredDevices => where((d) => !d.isWifi).toList(); +} diff --git a/lib/page/_shared/models/device_ui_model.dart b/lib/page/_shared/models/device_ui_model.dart deleted file mode 100644 index a84252b8d..000000000 --- a/lib/page/_shared/models/device_ui_model.dart +++ /dev/null @@ -1,314 +0,0 @@ -import 'package:equatable/equatable.dart'; -import 'package:privacy_gui/core/utils/wifi.dart'; - -// --------------------------------------------------------------------------- -// Additional Interface Info (for multi-interface devices) -// --------------------------------------------------------------------------- - -/// Information about an additional network interface for a device. -/// -/// When a device connects via multiple interfaces (e.g., WiFi + Ethernet), -/// the primary interface is stored in [DeviceUIModel] and additional -/// interfaces are stored in [DeviceUIModel.additionalInterfaces]. -class DeviceInterfaceInfo extends Equatable { - final String mac; - final String ip; - final bool isWifi; - final bool isActive; - final String layer1Interface; - final String? band; - final String? ssidName; - final int? signalStrength; - - const DeviceInterfaceInfo({ - required this.mac, - required this.ip, - required this.isWifi, - required this.isActive, - required this.layer1Interface, - this.band, - this.ssidName, - this.signalStrength, - }); - - @override - List get props => [ - mac, - ip, - isWifi, - isActive, - layer1Interface, - band, - ssidName, - signalStrength, - ]; -} - -// --------------------------------------------------------------------------- -// Connection Type Enum -// --------------------------------------------------------------------------- - -/// Connection type for UI display decisions. -enum DeviceConnectionType { wifi, wired } - -/// Presentation Layer Model — aggregates codegen + enricher per-device info. -/// -/// UI widgets depend only on this class, never directly on codegen Data Models. -/// Naming follows constitution Section 3.3.4 (class name ends with `UIModel`). -/// Implements [Equatable] per Article XI. -class DeviceUIModel extends Equatable { - // ─── Base info (from ConnectedDevice codegen) ─── - final String mac; // PhysAddress (uppercase, normalized) - final String ip; // IPAddress - final String hostName; // HostName - final bool isActive; // Active - final bool isWifi; // Derived from Layer1Interface - - // ─── WiFi enrichment (null if ethernet) ─── - final int? signalStrength; // RSSI dBm (from WifiClient) - final int? - downlinkRate; // kbps (from TR-181 LastDataDownlinkRate/LastDataUplinkRate) - final int? - uplinkRate; // kbps (from TR-181 LastDataDownlinkRate/LastDataUplinkRate) - final String? - band; // "2.4GHz" / "5GHz" / "6GHz" (from ClientConnectionDetail) - final String? ssidName; // SSID name (from ClientConnectionDetail) - - // ─── IPv6 addresses (from ConnectedDeviceIpv6 children) ─── - final List ipv6Addresses; - - // ─── Layer1 interface path (for port correlation) ─── - final String layer1Interface; // Raw TR-181 Layer1Interface path - - // ─── Mesh enrichment ─── - final String? parentNodeId; // Connected mesh node device ID - final String? parentNodeName; // Mesh node model name (display) - - // ─── Device classification (from Hosts) ─── - final String? deviceRole; // "master" / "slave" / "client" - final String? interfaceType; // "WiFi" / "Ethernet" / etc. - final String? friendlyName; // User-friendly device name - final String? manufacturer; // Device manufacturer - final String? modelName; // Device model name - final String? operatingSystem; // Device OS - final String? - hostsDeviceId; // Hosts DeviceID (UUID, last 12 chars = MAC for DataElements match) - - // ─── Multi-interface grouping (hostname-based) ─── - /// Additional interfaces for this device (when connected via multiple interfaces). - /// Primary interface data is stored in this model's fields; this list contains - /// secondary interfaces (e.g., if primary is WiFi, this may contain Ethernet). - final List additionalInterfaces; - - const DeviceUIModel({ - required this.mac, - required this.ip, - required this.hostName, - required this.isActive, - required this.isWifi, - this.layer1Interface = '', - this.signalStrength, - this.downlinkRate, - this.uplinkRate, - this.band, - this.ssidName, - this.ipv6Addresses = const [], - this.parentNodeId, - this.parentNodeName, - this.deviceRole, - this.interfaceType, - this.friendlyName, - this.manufacturer, - this.modelName, - this.operatingSystem, - this.hostsDeviceId, - this.additionalInterfaces = const [], - }); - - // ─── Computed getters ─── - - /// Display name: friendlyName > hostName > MAC. - String get displayName { - if (friendlyName != null && friendlyName!.isNotEmpty) return friendlyName!; - if (hostName.isNotEmpty) return hostName; - return mac; - } - - /// Signal quality: 0.0–1.0, mapped from RSSI. - /// -30 dBm (excellent) → 1.0, -90 dBm (poor) → 0.0 - double get signalQuality { - if (signalStrength == null) return 0; - return ((signalStrength! + 90) / 60).clamp(0.0, 1.0); - } - - /// Signal level: 0 (no signal) to 3 (excellent). - /// Uses thresholds from [getWifiSignalLevel] for consistency. - int get signalLevel { - if (signalStrength == null) return 0; - return switch (getWifiSignalLevel(signalStrength)) { - NodeSignalLevel.excellent => 3, - NodeSignalLevel.good => 2, - NodeSignalLevel.fair => 1, - NodeSignalLevel.poor || NodeSignalLevel.none => 0, - NodeSignalLevel.wired => 0, - }; - } - - /// Total throughput in bits/sec. - int get totalThroughput => (downlinkRate ?? 0) + (uplinkRate ?? 0); - - // ─── Display computed getters ─── - - /// Connection type enum (UI layer uses this for i18n lookup). - DeviceConnectionType get connectionType => - isWifi ? DeviceConnectionType.wifi : DeviceConnectionType.wired; - - /// Whether to display signal strength indicator. - /// True only for active WiFi devices with RSSI data. - bool get hasSignalDisplay => isActive && isWifi && signalStrength != null; - - /// Whether WiFi details card should be visible. - /// True only for active WiFi devices. - bool get shouldShowWifiDetails => isWifi && isActive; - - /// Whether any WiFi detail data is available (signal, speed, or band/SSID). - bool get hasWifiData => - signalStrength != null || - downlinkRate != null || - uplinkRate != null || - band != null || - ssidName != null; - - /// Whether the device tile should be interactive (tappable). - /// Offline devices are not interactive. - bool get isInteractive => isActive; - - /// Display opacity: 1.0 for active, 0.5 for offline devices. - double get displayOpacity => isActive ? 1.0 : 0.5; - - /// Whether this is a client device (not a mesh node master/slave). - bool get isClientDevice => deviceRole != 'master' && deviceRole != 'slave'; - - /// Whether this device is a mesh node (master or slave router). - bool get isMeshNode => deviceRole == 'master' || deviceRole == 'slave'; - - /// Whether this device is the master (gateway) mesh node. - bool get isMasterNode => deviceRole == 'master'; - - /// Whether this device is a slave (extender) mesh node. - bool get isSlaveNode => deviceRole == 'slave'; - - // ─── Multi-interface getters ─── - - /// Whether this device has multiple network interfaces. - bool get hasMultipleInterfaces => additionalInterfaces.isNotEmpty; - - /// All MAC addresses for this device (primary + additional interfaces). - List get allMacAddresses => - [mac, ...additionalInterfaces.map((i) => i.mac)]; - - /// Total number of interfaces for this device. - int get interfaceCount => 1 + additionalInterfaces.length; - - /// Whether any interface is active (primary or additional). - bool get hasAnyActiveInterface => - isActive || additionalInterfaces.any((i) => i.isActive); - - /// Signal strength display text (technical value, no i18n needed). - /// Returns null if no signal data available. - String? get signalDisplayText => - signalStrength != null ? '$signalStrength dBm' : null; - - /// Creates a copy with optional field overrides. - DeviceUIModel copyWith({ - String? mac, - String? ip, - String? hostName, - bool? isActive, - bool? isWifi, - String? layer1Interface, - int? signalStrength, - int? downlinkRate, - int? uplinkRate, - String? band, - String? ssidName, - List? ipv6Addresses, - String? parentNodeId, - String? parentNodeName, - String? deviceRole, - String? interfaceType, - String? friendlyName, - String? manufacturer, - String? modelName, - String? operatingSystem, - String? hostsDeviceId, - List? additionalInterfaces, - }) { - return DeviceUIModel( - mac: mac ?? this.mac, - ip: ip ?? this.ip, - hostName: hostName ?? this.hostName, - isActive: isActive ?? this.isActive, - isWifi: isWifi ?? this.isWifi, - layer1Interface: layer1Interface ?? this.layer1Interface, - signalStrength: signalStrength ?? this.signalStrength, - downlinkRate: downlinkRate ?? this.downlinkRate, - uplinkRate: uplinkRate ?? this.uplinkRate, - band: band ?? this.band, - ssidName: ssidName ?? this.ssidName, - ipv6Addresses: ipv6Addresses ?? this.ipv6Addresses, - parentNodeId: parentNodeId ?? this.parentNodeId, - parentNodeName: parentNodeName ?? this.parentNodeName, - deviceRole: deviceRole ?? this.deviceRole, - interfaceType: interfaceType ?? this.interfaceType, - friendlyName: friendlyName ?? this.friendlyName, - manufacturer: manufacturer ?? this.manufacturer, - modelName: modelName ?? this.modelName, - operatingSystem: operatingSystem ?? this.operatingSystem, - hostsDeviceId: hostsDeviceId ?? this.hostsDeviceId, - additionalInterfaces: additionalInterfaces ?? this.additionalInterfaces, - ); - } - - @override - List get props => [ - mac, - ip, - hostName, - isActive, - isWifi, - layer1Interface, - ipv6Addresses, - signalStrength, - downlinkRate, - uplinkRate, - band, - ssidName, - parentNodeId, - parentNodeName, - deviceRole, - interfaceType, - friendlyName, - manufacturer, - modelName, - operatingSystem, - hostsDeviceId, - additionalInterfaces, - ]; -} - -/// Extension methods for List to simplify common filtering. -extension DeviceUIModelListExt on List { - /// Returns only client devices (excludes mesh nodes). - List get clientDevices => - where((d) => d.isClientDevice).toList(); - - /// Returns only mesh nodes (master and slave routers). - List get meshNodes => where((d) => d.isMeshNode).toList(); - - /// Returns the master (gateway) node, or null if not found. - DeviceUIModel? get masterNode => where((d) => d.isMasterNode).firstOrNull; - - /// Returns all slave (extender) nodes. - List get slaveNodes => where((d) => d.isSlaveNode).toList(); -} diff --git a/lib/page/_shared/models/dhcp_client_ui_model.dart b/lib/page/_shared/models/dhcp_client_ui_model.dart index 8472f6f0c..f52ad66cc 100644 --- a/lib/page/_shared/models/dhcp_client_ui_model.dart +++ b/lib/page/_shared/models/dhcp_client_ui_model.dart @@ -1,20 +1,36 @@ import 'package:equatable/equatable.dart'; +import 'package:privacy_gui/framework/diagnostic_loggable.dart'; -/// Presentation Layer Model for an active DHCP client lease. -class DhcpClientUIModel extends Equatable { +/// Presentation Layer Model for a DHCP client lease. +/// +/// - [leaseActive]: Whether the DHCP lease is valid (from TR-181 DHCPv4.Server.Pool.*.Client.*.Active) +/// - [isOnline]: Whether the device is currently connected (from TR-181 Hosts.Host.*.Active) +class DhcpClientUIModel extends Equatable with DiagnosticLoggable { + /// MAC address (normalized to uppercase). final String mac; final String ip; - final bool active; + + /// Whether the DHCP lease is active (not expired). + /// This comes from `Device.DHCPv4.Server.Pool.*.Client.*.Active`. + final bool leaseActive; + + /// Whether the device is currently online (connected to the network). + /// This comes from `Device.Hosts.Host.*.Active` via join on MAC address. + /// Null if no matching host entry found. + final bool? isOnline; + final String hostName; final DateTime? leaseExpiry; - const DhcpClientUIModel({ - required this.mac, + /// Creates a DHCP client UI model. MAC is normalized to uppercase. + DhcpClientUIModel({ + required String mac, required this.ip, - required this.active, + required this.leaseActive, + this.isOnline, this.hostName = '', this.leaseExpiry, - }); + }) : mac = mac.toUpperCase(); /// Human-readable lease status. /// @@ -27,7 +43,7 @@ class DhcpClientUIModel extends Equatable { if (leaseExpiry == null) return ''; final remaining = leaseExpiry!.difference(DateTime.now()); if (remaining.isNegative) { - return active ? '' : 'Expired'; + return leaseActive ? '' : 'Expired'; } final days = remaining.inDays; final hours = remaining.inHours % 24; @@ -54,5 +70,15 @@ class DhcpClientUIModel extends Equatable { String get displayName => hostName.isNotEmpty ? hostName : mac; @override - List get props => [mac, ip, active, hostName, leaseExpiry]; + String get diagnosticName => 'DhcpClientUIModel'; + + @override + Map get namedProps => { + 'mac': mac, + 'ip': ip, + 'leaseActive': leaseActive, + 'isOnline': isOnline, + 'hostName': hostName, + 'leaseExpiry': leaseExpiry, + }; } diff --git a/lib/page/_shared/models/dhcp_reservation_ui_model.dart b/lib/page/_shared/models/dhcp_reservation_ui_model.dart index 057c41b55..01f5a843b 100644 --- a/lib/page/_shared/models/dhcp_reservation_ui_model.dart +++ b/lib/page/_shared/models/dhcp_reservation_ui_model.dart @@ -1,21 +1,25 @@ import 'package:equatable/equatable.dart'; +import 'package:privacy_gui/framework/diagnostic_loggable.dart'; /// Presentation Layer Model for a DHCP reservation. /// /// [instancePath] is `null` for newly created (local-only) reservations /// that have not yet been saved to the device. -class DhcpReservationUIModel extends Equatable { +class DhcpReservationUIModel extends Equatable with DiagnosticLoggable { final String? instancePath; + + /// MAC address (normalized to uppercase). final String mac; final String ip; final bool enable; - const DhcpReservationUIModel({ + /// Creates a DHCP reservation UI model. MAC is normalized to uppercase. + DhcpReservationUIModel({ this.instancePath, - required this.mac, + required String mac, required this.ip, required this.enable, - }); + }) : mac = mac.toUpperCase(); DhcpReservationUIModel copyWith({ String? instancePath, @@ -32,5 +36,13 @@ class DhcpReservationUIModel extends Equatable { } @override - List get props => [instancePath, mac, ip, enable]; + String get diagnosticName => 'DhcpReservationUIModel'; + + @override + Map get namedProps => { + 'instancePath': instancePath, + 'mac': mac, + 'ip': ip, + 'enable': enable, + }; } diff --git a/lib/page/_shared/models/ethernet_port_ui_model.dart b/lib/page/_shared/models/ethernet_port_ui_model.dart index cd0a52e3e..469cce476 100644 --- a/lib/page/_shared/models/ethernet_port_ui_model.dart +++ b/lib/page/_shared/models/ethernet_port_ui_model.dart @@ -1,7 +1,8 @@ import 'package:equatable/equatable.dart'; +import 'package:privacy_gui/framework/diagnostic_loggable.dart'; /// A wired device connected to an Ethernet port. -class WiredDeviceInfo extends Equatable { +class WiredDeviceInfo extends Equatable with DiagnosticLoggable { final String hostName; final String macAddress; final String ipAddress; @@ -15,11 +16,18 @@ class WiredDeviceInfo extends Equatable { String get displayName => hostName.isNotEmpty ? hostName : macAddress; @override - List get props => [hostName, macAddress, ipAddress]; + String get diagnosticName => 'WiredDeviceInfo'; + + @override + Map get namedProps => { + 'hostName': hostName, + 'macAddress': macAddress, + 'ipAddress': ipAddress, + }; } /// Presentation Layer Model for a physical Ethernet port. -class EthernetPortUIModel extends Equatable { +class EthernetPortUIModel extends Equatable with DiagnosticLoggable { final String name; final String label; final bool isWan; @@ -50,13 +58,16 @@ class EthernetPortUIModel extends Equatable { } @override - List get props => [ - name, - label, - isWan, - isUp, - instancePath, - currentBitRate, - connectedDevices, - ]; + String get diagnosticName => 'EthernetPortUIModel'; + + @override + Map get namedProps => { + 'name': name, + 'label': label, + 'isWan': isWan, + 'isUp': isUp, + 'instancePath': instancePath, + 'currentBitRate': currentBitRate, + 'connectedDevices': connectedDevices, + }; } diff --git a/lib/page/_shared/models/lan_info_ui_model.dart b/lib/page/_shared/models/lan_info_ui_model.dart index d8a65d060..5225cbc33 100644 --- a/lib/page/_shared/models/lan_info_ui_model.dart +++ b/lib/page/_shared/models/lan_info_ui_model.dart @@ -1,10 +1,11 @@ import 'package:equatable/equatable.dart'; +import 'package:privacy_gui/framework/diagnostic_loggable.dart'; /// Presentation Layer Model for LAN configuration info. /// /// Naming follows constitution Section 3.3.4 (class name ends with `UIModel`). /// Implements [Equatable] per Article XI. -class LanInfoUIModel extends Equatable { +class LanInfoUIModel extends Equatable with DiagnosticLoggable { final String hostName; final String ipAddress; final String subnetMask; @@ -35,14 +36,19 @@ class LanInfoUIModel extends Equatable { : 'N/A'; @override - List get props => [ - ipAddress, - subnetMask, - dhcpEnabled, - minAddress, - maxAddress, - dnsServers, - ipv6Enabled, - ipv6Addresses, - ]; + String get diagnosticName => 'LanInfoUIModel'; + + @override + Map get namedProps => { + 'hostName': hostName, + 'ipAddress': ipAddress, + 'subnetMask': subnetMask, + 'dhcpEnabled': dhcpEnabled, + 'minAddress': minAddress, + 'maxAddress': maxAddress, + 'leaseTimeMinutes': leaseTimeMinutes, + 'dnsServers': dnsServers, + 'ipv6Enabled': ipv6Enabled, + 'ipv6Addresses': ipv6Addresses, + }; } diff --git a/lib/page/_shared/models/mesh_network.dart b/lib/page/_shared/models/mesh_network.dart new file mode 100644 index 000000000..961aee38c --- /dev/null +++ b/lib/page/_shared/models/mesh_network.dart @@ -0,0 +1,143 @@ +import 'package:equatable/equatable.dart'; +import 'package:privacy_gui/framework/diagnostic_loggable.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; + +/// Top-level container for mesh network topology. +/// +/// Single Source of Truth (SSoT) for all network entities. +/// Contains one master node, zero or more slave nodes, and all client devices +/// organized by their parent node. +class MeshNetwork with EquatableMixin, DiagnosticNamed { + /// The master (gateway) node. + final MasterNode master; + + /// Slave (extender) nodes. + final List slaves; + + /// Clients not yet assigned to a node (mesh data timing issue). + /// + /// When Hosts data arrives before DataElements, clients may not have + /// parentNodeId. These are stored here until mesh topology is available. + final List unassignedClients; + + MeshNetwork({ + required this.master, + this.slaves = const [], + this.unassignedClients = const [], + }); + + // ─── Accessors ─── + + /// All mesh nodes (master + slaves). + List get allNodes => [master, ...slaves]; + + /// All client devices across all nodes + unassigned. + List get allClients => [ + ...master.connectedClients, + ...slaves.expand((s) => s.connectedClients), + ...unassignedClients, + ]; + + /// Total number of client devices. + int get totalClientCount => allClients.length; + + /// Number of online client devices. + int get onlineClientCount => allClients.where((c) => c.isOnline).length; + + /// Number of offline client devices. + int get offlineClientCount => allClients.where((c) => !c.isOnline).length; + + /// Whether this is a mesh network (has slave nodes). + bool get hasMesh => slaves.isNotEmpty; + + /// Total number of nodes. + int get nodeCount => 1 + slaves.length; + + // ─── Lookups ─── + + /// Find a node by device ID (supports both deviceId and dataElementsId). + NodeEntity? findNode(String id) { + final normalized = id.toUpperCase(); + for (final node in allNodes) { + if (node.deviceId.toUpperCase() == normalized) return node; + if (node.dataElementsId?.toUpperCase() == normalized) return node; + } + return null; + } + + /// Find a client device by MAC address. + ClientDevice? findClient(String mac) { + final normalized = mac.toUpperCase(); + for (final client in allClients) { + if (client.mac.toUpperCase() == normalized) return client; + // Also check additional interfaces + for (final iface in client.additionalInterfaces) { + if (iface.mac.toUpperCase() == normalized) return client; + } + } + return null; + } + + /// Find the parent node for a client device. + NodeEntity? findParentNode(ClientDevice client) { + if (client.parentNodeId == null) return master; + return findNode(client.parentNodeId!); + } + + /// Get all clients connected to a specific node. + List clientsForNode(String nodeId) { + final node = findNode(nodeId); + return node?.connectedClients ?? []; + } + + // ─── Statistics ─── + + /// WiFi client count (online only). + int get wifiClientCount => + allClients.where((c) => c.isOnline && c.isWifi).length; + + /// Wired client count (online only). + int get wiredClientCount => + allClients.where((c) => c.isOnline && !c.isWifi).length; + + /// Clients grouped by parent node ID. + Map> get clientsByNode { + final result = >{}; + result[master.deviceId] = master.connectedClients; + for (final slave in slaves) { + result[slave.deviceId] = slave.connectedClients; + } + if (unassignedClients.isNotEmpty) { + result['_unassigned'] = unassignedClients; + } + return result; + } + + // ─── Copy ─── + + MeshNetwork copyWith({ + MasterNode? master, + List? slaves, + List? unassignedClients, + }) { + return MeshNetwork( + master: master ?? this.master, + slaves: slaves ?? this.slaves, + unassignedClients: unassignedClients ?? this.unassignedClients, + ); + } + + @override + List get props => [master, slaves, unassignedClients]; + + @override + String get diagnosticName => 'MeshNetwork'; + + @override + Map get namedProps => { + 'master': master, + 'slaves': slaves, + 'unassignedClients': unassignedClients, + }; +} diff --git a/lib/page/_shared/models/mesh_topology_info.dart b/lib/page/_shared/models/mesh_topology_info.dart index 4aa2679aa..59f3c30ed 100644 --- a/lib/page/_shared/models/mesh_topology_info.dart +++ b/lib/page/_shared/models/mesh_topology_info.dart @@ -1,28 +1,61 @@ import 'package:equatable/equatable.dart'; -import 'package:privacy_gui/page/topology/models/node_ui_model.dart'; +import 'package:privacy_gui/framework/diagnostic_loggable.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; /// Result of mesh topology fetch from DataElements. /// /// Contains mesh nodes and client-to-node mapping for determining /// which mesh node each client device is connected to. -class MeshTopologyInfo extends Equatable { +/// +/// NOTE: The [nodes] list contains NodeEntity instances with empty +/// [connectedClients] — client assignment happens in [MeshNetworkBuilder]. +class MeshTopologyInfo extends Equatable with DiagnosticLoggable { /// Mesh nodes discovered via DataElements. - final List nodes; + final List nodes; /// Client MAC (uppercase) → node device ID mapping. final Map clientToNodeMap; + /// Client MAC (uppercase) → signal strength (RSSI dBm). + /// + /// Populated from DataElements STA.SignalStrength for clients on ALL nodes, + /// including child nodes. Used as fallback when WifiClients doesn't have + /// signal data (WifiClients only covers master node clients). + final Map clientSignalMap; + + /// Client MAC (uppercase) → (band, ssid) from DataElements BSS. + /// + /// Populated from DataElements BSS for clients on ALL nodes, + /// including child nodes. Used as fallback when connectionDetailMap + /// doesn't have band/SSID data (connectionDetailMap only covers master clients). + final Map clientBandSsidMap; + const MeshTopologyInfo({ required this.nodes, required this.clientToNodeMap, + this.clientSignalMap = const {}, + this.clientBandSsidMap = const {}, }); /// Empty result — used as fallback when DataElements is not supported. - static const empty = MeshTopologyInfo(nodes: [], clientToNodeMap: {}); + static const empty = MeshTopologyInfo( + nodes: [], + clientToNodeMap: {}, + clientSignalMap: {}, + clientBandSsidMap: {}, + ); bool get isEmpty => nodes.isEmpty; bool get isNotEmpty => nodes.isNotEmpty; @override - List get props => [nodes, clientToNodeMap]; + String get diagnosticName => 'MeshTopologyInfo'; + + @override + Map get namedProps => { + 'nodes': nodes, + 'clientToNodeMap': clientToNodeMap, + 'clientSignalMap': clientSignalMap, + 'clientBandSsidMap': clientBandSsidMap, + }; } diff --git a/lib/page/_shared/models/network_entity.dart b/lib/page/_shared/models/network_entity.dart new file mode 100644 index 000000000..6aeb11a29 --- /dev/null +++ b/lib/page/_shared/models/network_entity.dart @@ -0,0 +1,22 @@ +import 'package:equatable/equatable.dart'; + +/// Abstract interface for all network entities (nodes and clients). +/// +/// Provides a common interface for identity and display across the +/// MeshNetwork architecture. Implementers: [NodeEntity], [ClientDevice]. +abstract class NetworkEntity with EquatableMixin { + /// Unique identifier (MAC address, normalized uppercase). + String get id; + + /// Display name for UI (computed from available name fields). + String get displayName; + + /// Whether the entity is currently online/active. + bool get isOnline; + + /// Primary IPv4 address, or null if unavailable. + String? get ipAddress; + + /// All IPv6 addresses for this entity. + List get ipv6Addresses; +} diff --git a/lib/page/_shared/models/node_entity.dart b/lib/page/_shared/models/node_entity.dart new file mode 100644 index 000000000..185bf2533 --- /dev/null +++ b/lib/page/_shared/models/node_entity.dart @@ -0,0 +1,364 @@ +import 'package:privacy_gui/framework/diagnostic_loggable.dart'; +import 'package:privacy_gui/page/_shared/models/backhaul_info.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; +import 'package:privacy_gui/page/_shared/models/network_entity.dart'; + +/// Sealed class for mesh network nodes (master or slave). +/// +/// Use pattern matching to distinguish between [MasterNode] and [SlaveNode]: +/// ```dart +/// switch (node) { +/// case MasterNode m: print('Gateway: ${m.wanIpAddress}'); +/// case SlaveNode s: print('Extender via ${s.backhaul.linkType}'); +/// } +/// ``` +sealed class NodeEntity extends NetworkEntity with DiagnosticNamed { + // ─── Identity ─── + /// Device ID (MAC address, uppercase, normalized). + String get deviceId; + + /// DataElements ID (may differ from deviceId for matching). + String? get dataElementsId; + + /// User-friendly name. + String? get friendlyName; + + /// Hostname from Hosts table. + String? get hostName; + + // ─── Device Info ─── + /// Model name (e.g., "MR7500"). + String get model; + + /// Manufacturer name. + String get manufacturer; + + /// Serial number. + String get serialNumber; + + /// Firmware version. + String get softwareVersion; + + // ─── Network ─── + /// LAN IP address. + @override + String? get ipAddress; + + /// LAN IPv6 addresses. + @override + List get ipv6Addresses; + + // ─── DataElements internal ─── + /// DataElements instance path. + String? get instancePath; + + // ─── Children ─── + /// Client devices connected to this node. + List get connectedClients; + + // ─── NetworkEntity implementation ─── + @override + String get id => deviceId; + + @override + String get displayName { + if (friendlyName != null && friendlyName!.isNotEmpty) return friendlyName!; + if (hostName != null && hostName!.isNotEmpty) return hostName!; + if (model.isNotEmpty) return model; + return deviceId; + } + + @override + bool get isOnline => true; // Nodes are always online if visible + + // ─── Computed ─── + /// Whether this is the master (gateway) node. + bool get isMaster; + + /// Role label for UI display. + String get roleLabel => isMaster ? 'Master' : 'Slave'; + + /// Number of connected clients. + int get connectedDeviceCount => connectedClients.length; +} + +/// Master (gateway) mesh node. +/// +/// The primary router that connects to the internet via WAN. +final class MasterNode extends NodeEntity { + @override + final String deviceId; + @override + final String? dataElementsId; + @override + final String? friendlyName; + @override + final String? hostName; + @override + final String model; + @override + final String manufacturer; + @override + final String serialNumber; + @override + final String softwareVersion; + @override + final String? ipAddress; + @override + final List ipv6Addresses; + @override + final String? instancePath; + @override + final List connectedClients; + + /// WAN IPv4 address. + final String? wanIpAddress; + + /// WAN IPv6 address. + final String? wanIpv6Address; + + /// Hosts DeviceID (UUID) — used by Remote Assistance / Guardian API calls. + final String? hostsDeviceId; + + MasterNode({ + required this.deviceId, + this.dataElementsId, + this.friendlyName, + this.hostName, + required this.model, + this.manufacturer = '', + this.serialNumber = '', + this.softwareVersion = '', + this.ipAddress, + this.ipv6Addresses = const [], + this.instancePath, + this.connectedClients = const [], + this.wanIpAddress, + this.wanIpv6Address, + this.hostsDeviceId, + }); + + @override + bool get isMaster => true; + + MasterNode copyWith({ + String? deviceId, + String? dataElementsId, + String? friendlyName, + String? hostName, + String? model, + String? manufacturer, + String? serialNumber, + String? softwareVersion, + String? ipAddress, + List? ipv6Addresses, + String? instancePath, + List? connectedClients, + String? wanIpAddress, + String? wanIpv6Address, + String? hostsDeviceId, + }) { + return MasterNode( + deviceId: deviceId ?? this.deviceId, + dataElementsId: dataElementsId ?? this.dataElementsId, + friendlyName: friendlyName ?? this.friendlyName, + hostName: hostName ?? this.hostName, + model: model ?? this.model, + manufacturer: manufacturer ?? this.manufacturer, + serialNumber: serialNumber ?? this.serialNumber, + softwareVersion: softwareVersion ?? this.softwareVersion, + ipAddress: ipAddress ?? this.ipAddress, + ipv6Addresses: ipv6Addresses ?? this.ipv6Addresses, + instancePath: instancePath ?? this.instancePath, + connectedClients: connectedClients ?? this.connectedClients, + wanIpAddress: wanIpAddress ?? this.wanIpAddress, + wanIpv6Address: wanIpv6Address ?? this.wanIpv6Address, + hostsDeviceId: hostsDeviceId ?? this.hostsDeviceId, + ); + } + + @override + List get props => [ + deviceId, + dataElementsId, + friendlyName, + hostName, + model, + manufacturer, + serialNumber, + softwareVersion, + ipAddress, + ipv6Addresses, + instancePath, + connectedClients, + wanIpAddress, + wanIpv6Address, + hostsDeviceId, + ]; + + @override + String get diagnosticName => 'MasterNode'; + + @override + Map get namedProps => { + 'deviceId': deviceId, + 'dataElementsId': dataElementsId, + 'friendlyName': friendlyName, + 'hostName': hostName, + 'model': model, + 'manufacturer': manufacturer, + 'serialNumber': serialNumber, + 'softwareVersion': softwareVersion, + 'ipAddress': ipAddress, + 'ipv6Addresses': ipv6Addresses, + 'instancePath': instancePath, + 'connectedClients': connectedClients, + 'wanIpAddress': wanIpAddress, + 'wanIpv6Address': wanIpv6Address, + 'hostsDeviceId': hostsDeviceId, + }; +} + +/// Slave (extender) mesh node. +/// +/// Extends the network via WiFi or Ethernet backhaul to the parent node. +final class SlaveNode extends NodeEntity { + @override + final String deviceId; + @override + final String? dataElementsId; + @override + final String? friendlyName; + @override + final String? hostName; + @override + final String model; + @override + final String manufacturer; + @override + final String serialNumber; + @override + final String softwareVersion; + @override + final String? ipAddress; + @override + final List ipv6Addresses; + @override + final String? instancePath; + @override + final List connectedClients; + + /// Backhaul connection info to parent node. + final BackhaulInfo backhaul; + + SlaveNode({ + required this.deviceId, + this.dataElementsId, + this.friendlyName, + this.hostName, + required this.model, + this.manufacturer = '', + this.serialNumber = '', + this.softwareVersion = '', + this.ipAddress, + this.ipv6Addresses = const [], + this.instancePath, + this.connectedClients = const [], + required this.backhaul, + }); + + @override + bool get isMaster => false; + + /// Whether backhaul is Ethernet. + bool get isEthernetBackhaul => backhaul.isEthernet; + + /// Whether backhaul info is available. + bool get hasBackhaul => backhaul.hasInfo; + + SlaveNode copyWith({ + String? deviceId, + String? dataElementsId, + String? friendlyName, + String? hostName, + String? model, + String? manufacturer, + String? serialNumber, + String? softwareVersion, + String? ipAddress, + List? ipv6Addresses, + String? instancePath, + List? connectedClients, + BackhaulInfo? backhaul, + }) { + return SlaveNode( + deviceId: deviceId ?? this.deviceId, + dataElementsId: dataElementsId ?? this.dataElementsId, + friendlyName: friendlyName ?? this.friendlyName, + hostName: hostName ?? this.hostName, + model: model ?? this.model, + manufacturer: manufacturer ?? this.manufacturer, + serialNumber: serialNumber ?? this.serialNumber, + softwareVersion: softwareVersion ?? this.softwareVersion, + ipAddress: ipAddress ?? this.ipAddress, + ipv6Addresses: ipv6Addresses ?? this.ipv6Addresses, + instancePath: instancePath ?? this.instancePath, + connectedClients: connectedClients ?? this.connectedClients, + backhaul: backhaul ?? this.backhaul, + ); + } + + @override + List get props => [ + deviceId, + dataElementsId, + friendlyName, + hostName, + model, + manufacturer, + serialNumber, + softwareVersion, + ipAddress, + ipv6Addresses, + instancePath, + connectedClients, + backhaul, + ]; + + @override + String get diagnosticName => 'SlaveNode'; + + @override + Map get namedProps => { + 'deviceId': deviceId, + 'dataElementsId': dataElementsId, + 'friendlyName': friendlyName, + 'hostName': hostName, + 'model': model, + 'manufacturer': manufacturer, + 'serialNumber': serialNumber, + 'softwareVersion': softwareVersion, + 'ipAddress': ipAddress, + 'ipv6Addresses': ipv6Addresses, + 'instancePath': instancePath, + 'connectedClients': connectedClients, + 'backhaul': backhaul, + }; +} + +/// Extension methods for List. +extension NodeEntityListExt on List { + /// Returns the master (gateway) node, or null if not found. + MasterNode? get master { + for (final n in this) { + if (n is MasterNode) return n; + } + return null; + } + + /// Returns all slave (extender) nodes. + List get slaves => whereType().toList(); + + /// Whether this topology has mesh extenders. + bool get hasMesh => any((n) => n is SlaveNode); +} diff --git a/lib/page/_shared/models/pdf_report_data.dart b/lib/page/_shared/models/pdf_report_data.dart index 66ace0974..67559ecb9 100644 --- a/lib/page/_shared/models/pdf_report_data.dart +++ b/lib/page/_shared/models/pdf_report_data.dart @@ -2,9 +2,9 @@ import 'package:privacy_gui/page/_shared/models/device_analytics_state.dart'; import 'package:privacy_gui/page/_shared/models/ethernet_port_ui_model.dart'; import 'package:privacy_gui/page/_shared/models/system_monitor_state.dart'; import 'package:privacy_gui/page/_shared/models/traffic_analysis_state.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; import 'package:privacy_gui/page/_shared/models/wifi_radio_ui_model.dart'; -import 'package:privacy_gui/page/topology/models/node_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; import 'package:privacy_gui/page/_shared/models/dhcp_client_ui_model.dart'; import 'package:privacy_gui/page/_shared/models/dhcp_reservation_ui_model.dart'; import 'package:privacy_gui/page/_shared/models/lan_info_ui_model.dart'; @@ -78,11 +78,11 @@ class PdfReportData { /// WiFi radio models (from wifiDataProvider). final List? radioModels; - /// Device UI models (from devicesDataProvider). - final List? deviceModels; + /// Client devices (from devicesDataProvider.meshNetwork.allClients). + final List? clientDevices; - /// Node UI models (from devicesDataProvider). - final List? nodeModels; + /// Mesh nodes (from devicesDataProvider.meshNetwork.allNodes). + final List? nodes; const PdfReportData({ this.ethernetPortModels, @@ -103,7 +103,7 @@ class PdfReportData { this.wanStatus, this.systemInfo, this.radioModels, - this.deviceModels, - this.nodeModels, + this.clientDevices, + this.nodes, }); } diff --git a/lib/page/_shared/models/port_forwarding_rule_ui_model.dart b/lib/page/_shared/models/port_forwarding_rule_ui_model.dart index 50f166217..9f6518efb 100644 --- a/lib/page/_shared/models/port_forwarding_rule_ui_model.dart +++ b/lib/page/_shared/models/port_forwarding_rule_ui_model.dart @@ -1,4 +1,5 @@ import 'package:equatable/equatable.dart'; +import 'package:privacy_gui/framework/diagnostic_loggable.dart'; /// Presentation Layer Model for a port forwarding rule. /// @@ -9,7 +10,7 @@ import 'package:equatable/equatable.dart'; /// /// [instancePath] is `null` for newly created (local-only) rules /// that have not yet been saved to the device. -class PortForwardingRuleUIModel extends Equatable { +class PortForwardingRuleUIModel extends Equatable with DiagnosticLoggable { final String? instancePath; final String description; final int externalPort; @@ -72,14 +73,17 @@ class PortForwardingRuleUIModel extends Equatable { '$portRangeDisplay \u2192 $internalClient:$internalPort'; @override - List get props => [ - instancePath, - description, - externalPort, - externalPortEndRange, - internalPort, - internalClient, - protocol, - enabled, - ]; + String get diagnosticName => 'PortForwardingRuleUIModel'; + + @override + Map get namedProps => { + 'instancePath': instancePath, + 'description': description, + 'externalPort': externalPort, + 'externalPortEndRange': externalPortEndRange, + 'internalPort': internalPort, + 'internalClient': internalClient, + 'protocol': protocol, + 'enabled': enabled, + }; } diff --git a/lib/page/_shared/models/system_info_ui_model.dart b/lib/page/_shared/models/system_info_ui_model.dart index ca17335ce..77f00dec2 100644 --- a/lib/page/_shared/models/system_info_ui_model.dart +++ b/lib/page/_shared/models/system_info_ui_model.dart @@ -1,8 +1,9 @@ import 'package:equatable/equatable.dart'; +import 'package:privacy_gui/framework/diagnostic_loggable.dart'; import 'package:privacy_gui/page/_shared/utils/usp_formatters.dart'; /// Presentation Layer Model for a firmware image partition. -class FirmwareImageUIModel extends Equatable { +class FirmwareImageUIModel extends Equatable with DiagnosticLoggable { final String instancePath; final String name; final String version; @@ -22,19 +23,22 @@ class FirmwareImageUIModel extends Equatable { }); @override - List get props => [ - instancePath, - name, - version, - status, - available, - isActive, - isBootTarget, - ]; + String get diagnosticName => 'FirmwareImageUIModel'; + + @override + Map get namedProps => { + 'instancePath': instancePath, + 'name': name, + 'version': version, + 'status': status, + 'available': available, + 'isActive': isActive, + 'isBootTarget': isBootTarget, + }; } /// Presentation Layer Model for router system information. -class SystemInfoUIModel extends Equatable { +class SystemInfoUIModel extends Equatable with DiagnosticLoggable { final String manufacturer; final String modelName; final String serialNumber; @@ -95,16 +99,19 @@ class SystemInfoUIModel extends Equatable { } @override - List get props => [ - manufacturer, - modelName, - serialNumber, - hardwareVersion, - softwareVersion, - uptime, - totalMemory, - freeMemory, - cpuUsage, - firmwareImages, - ]; + String get diagnosticName => 'SystemInfoUIModel'; + + @override + Map get namedProps => { + 'manufacturer': manufacturer, + 'modelName': modelName, + 'serialNumber': serialNumber, + 'hardwareVersion': hardwareVersion, + 'softwareVersion': softwareVersion, + 'uptime': uptime, + 'totalMemory': totalMemory, + 'freeMemory': freeMemory, + 'cpuUsage': cpuUsage, + 'firmwareImages': firmwareImages, + }; } diff --git a/lib/page/_shared/models/system_monitor_state.dart b/lib/page/_shared/models/system_monitor_state.dart index dc28db676..175039e95 100644 --- a/lib/page/_shared/models/system_monitor_state.dart +++ b/lib/page/_shared/models/system_monitor_state.dart @@ -7,6 +7,7 @@ class SystemSnapshot extends Equatable { final int memoryPercent; final int totalMemoryKb; final int freeMemoryKb; + final int uptimeSeconds; const SystemSnapshot({ required this.timestamp, @@ -14,10 +15,23 @@ class SystemSnapshot extends Equatable { required this.memoryPercent, required this.totalMemoryKb, required this.freeMemoryKb, + required this.uptimeSeconds, }); int get usedMemoryKb => totalMemoryKb - freeMemoryKb; + /// Formatted uptime string (e.g. "2d 5h 30m"). + String get formattedUptime { + final days = uptimeSeconds ~/ 86400; + final hours = (uptimeSeconds % 86400) ~/ 3600; + final minutes = (uptimeSeconds % 3600) ~/ 60; + final parts = []; + if (days > 0) parts.add('${days}d'); + if (hours > 0) parts.add('${hours}h'); + if (minutes > 0 || parts.isEmpty) parts.add('${minutes}m'); + return parts.join(' '); + } + @override List get props => [ timestamp, @@ -25,6 +39,7 @@ class SystemSnapshot extends Equatable { memoryPercent, totalMemoryKb, freeMemoryKb, + uptimeSeconds, ]; } diff --git a/lib/page/_shared/models/time_settings_ui_model.dart b/lib/page/_shared/models/time_settings_ui_model.dart index a014da82e..569fa0199 100644 --- a/lib/page/_shared/models/time_settings_ui_model.dart +++ b/lib/page/_shared/models/time_settings_ui_model.dart @@ -1,8 +1,9 @@ import 'package:equatable/equatable.dart'; +import 'package:privacy_gui/framework/diagnostic_loggable.dart'; import 'package:privacy_gui/page/_shared/models/timezone_definitions.dart'; /// Presentation Layer Model for time settings. -class TimeSettingsUIModel extends Equatable { +class TimeSettingsUIModel extends Equatable with DiagnosticLoggable { final bool enable; final String status; final String currentLocalTime; @@ -71,12 +72,15 @@ class TimeSettingsUIModel extends Equatable { } @override - List get props => [ - enable, - status, - currentLocalTime, - localTimeZone, - ntpServer1, - ntpServer2, - ]; + String get diagnosticName => 'TimeSettingsUIModel'; + + @override + Map get namedProps => { + 'enable': enable, + 'status': status, + 'currentLocalTime': currentLocalTime, + 'localTimeZone': localTimeZone, + 'ntpServer1': ntpServer1, + 'ntpServer2': ntpServer2, + }; } diff --git a/lib/page/_shared/models/wan_status_ui_model.dart b/lib/page/_shared/models/wan_status_ui_model.dart index 169963951..8f26ab372 100644 --- a/lib/page/_shared/models/wan_status_ui_model.dart +++ b/lib/page/_shared/models/wan_status_ui_model.dart @@ -1,10 +1,11 @@ import 'package:equatable/equatable.dart'; +import 'package:privacy_gui/framework/diagnostic_loggable.dart'; /// Presentation Layer Model for WAN interface status. /// /// Naming follows constitution Section 3.3.4 (class name ends with `UIModel`). /// Implements [Equatable] per Article XI. -class WanStatusUIModel extends Equatable { +class WanStatusUIModel extends Equatable with DiagnosticLoggable { final bool isUp; final String ipAddress; final String subnetMask; @@ -26,14 +27,17 @@ class WanStatusUIModel extends Equatable { }); @override - List get props => [ - isUp, - ipAddress, - subnetMask, - addressingType, - mtu, - gateway, - ipv6Enabled, - ipv6Addresses, - ]; + String get diagnosticName => 'WanStatusUIModel'; + + @override + Map get namedProps => { + 'isUp': isUp, + 'ipAddress': ipAddress, + 'subnetMask': subnetMask, + 'addressingType': addressingType, + 'mtu': mtu, + 'gateway': gateway, + 'ipv6Enabled': ipv6Enabled, + 'ipv6Addresses': ipv6Addresses, + }; } diff --git a/lib/page/_shared/models/wifi_client_ui_model.dart b/lib/page/_shared/models/wifi_client_ui_model.dart index f538d461d..cf0592468 100644 --- a/lib/page/_shared/models/wifi_client_ui_model.dart +++ b/lib/page/_shared/models/wifi_client_ui_model.dart @@ -1,10 +1,11 @@ import 'package:equatable/equatable.dart'; +import 'package:privacy_gui/framework/diagnostic_loggable.dart'; /// Presentation-layer model for a WiFi associated device. /// /// Contains only the fields that Views and UI helpers need. /// Decouples the UI from the codegen `WifiClient` type. -class WifiClientUIModel extends Equatable { +class WifiClientUIModel extends Equatable with DiagnosticLoggable { final String macAddress; final int signalStrength; // RSSI in dBm final int noise; // noise floor in dBm @@ -22,12 +23,15 @@ class WifiClientUIModel extends Equatable { }); @override - List get props => [ - macAddress, - signalStrength, - noise, - lastDataDownlinkRate, - lastDataUplinkRate, - active, - ]; + String get diagnosticName => 'WifiClientUIModel'; + + @override + Map get namedProps => { + 'macAddress': macAddress, + 'signalStrength': signalStrength, + 'noise': noise, + 'lastDataDownlinkRate': lastDataDownlinkRate, + 'lastDataUplinkRate': lastDataUplinkRate, + 'active': active, + }; } diff --git a/lib/page/_shared/models/wifi_connection_info.dart b/lib/page/_shared/models/wifi_connection_info.dart new file mode 100644 index 000000000..3c9b49fbe --- /dev/null +++ b/lib/page/_shared/models/wifi_connection_info.dart @@ -0,0 +1,86 @@ +import 'package:equatable/equatable.dart'; +import 'package:privacy_gui/core/utils/wifi.dart'; +import 'package:privacy_gui/framework/diagnostic_loggable.dart'; + +/// WiFi connection details for a client device. +/// +/// Encapsulates signal strength, band, SSID, and throughput metrics. +/// Null fields indicate data not available (e.g., wired device or no enrichment). +class WifiConnectionInfo with EquatableMixin, DiagnosticNamed { + /// Signal strength in dBm (RSSI). Typically -30 to -90. + final int? signalStrength; + + /// WiFi band: "2.4GHz", "5GHz", or "6GHz". + final String? band; + + /// SSID name the client is connected to. + final String? ssidName; + + /// Downlink data rate in kbps. + final int? downlinkRate; + + /// Uplink data rate in kbps. + final int? uplinkRate; + + const WifiConnectionInfo({ + this.signalStrength, + this.band, + this.ssidName, + this.downlinkRate, + this.uplinkRate, + }); + + /// Signal quality as a normalized value (0.0–1.0). + /// + /// -30 dBm (excellent) → 1.0, -90 dBm (poor) → 0.0. + double get signalQuality { + if (signalStrength == null) return 0; + return ((signalStrength! + 90) / 60).clamp(0.0, 1.0); + } + + /// Signal level (0–3) using standard RSSI thresholds. + /// + /// 3 = Excellent (>= -65), 2 = Good, 1 = Fair, 0 = Poor. + int get signalLevel { + if (signalStrength == null) return 0; + return switch (getWifiSignalLevel(signalStrength)) { + NodeSignalLevel.excellent => 3, + NodeSignalLevel.good => 2, + NodeSignalLevel.fair => 1, + NodeSignalLevel.poor || NodeSignalLevel.none => 0, + NodeSignalLevel.wired => 0, + }; + } + + /// Total throughput in kbps (downlink + uplink). + int get totalThroughput => (downlinkRate ?? 0) + (uplinkRate ?? 0); + + /// Whether any WiFi data is available. + bool get hasData => + signalStrength != null || + band != null || + ssidName != null || + downlinkRate != null || + uplinkRate != null; + + @override + List get props => [ + signalStrength, + band, + ssidName, + downlinkRate, + uplinkRate, + ]; + + @override + String get diagnosticName => 'WifiConnectionInfo'; + + @override + Map get namedProps => { + 'signalStrength': signalStrength, + 'band': band, + 'ssidName': ssidName, + 'downlinkRate': downlinkRate, + 'uplinkRate': uplinkRate, + }; +} diff --git a/lib/page/_shared/models/wifi_radio_ui_model.dart b/lib/page/_shared/models/wifi_radio_ui_model.dart index 4e2865f54..b7780b170 100644 --- a/lib/page/_shared/models/wifi_radio_ui_model.dart +++ b/lib/page/_shared/models/wifi_radio_ui_model.dart @@ -1,7 +1,8 @@ import 'package:equatable/equatable.dart'; +import 'package:privacy_gui/framework/diagnostic_loggable.dart'; /// Presentation Layer Model for a WiFi radio with its access points. -class WifiRadioUIModel extends Equatable { +class WifiRadioUIModel extends Equatable with DiagnosticLoggable { final String instancePath; final String band; // "2.4GHz", "5GHz", "6GHz" final bool enable; @@ -12,6 +13,27 @@ class WifiRadioUIModel extends Equatable { final String channelBandwidth; final String supportedStandards; + /// Manually-selectable channels for this radio's band, sorted ascending. + /// + /// Sourced from `Device.WiFi.Radio.{i}.PossibleChannels` during the + /// dashboard data fetch ([UspWifiDataService.fetch]), so the edit-channel + /// dialog can render its dropdown synchronously with no per-dialog fetch. + /// Empty when the band exposes no manual channels. + /// + /// NOTE: this list is RAW — it is NOT DFS-filtered. On the dashboard path, + /// DFS (IEEE 802.11h) channels are hidden at display time in + /// [WifiChannelDialog] via `filterDfsChannels` keyed off [isDfsEnabled]. (The + /// WiFi Settings path pre-filters instead, in + /// `UspWifiSettingsService.buildWifiNetworks`, before building bandwidth + /// maps.) Any new consumer needing DFS-off filtering must call + /// `filterDfsChannels` itself. + final List possibleChannels; + + /// Per-radio DFS (IEEE 802.11h) enabled state, from + /// `Device.WiFi.Radio.{i}.IEEE80211hEnabled`. When false, 5 GHz DFS channels + /// must be hidden from the channel dropdown. + final bool isDfsEnabled; + /// Access points grouped under this radio. final List accessPoints; @@ -25,6 +47,8 @@ class WifiRadioUIModel extends Equatable { required this.autoChannelEnable, required this.channelBandwidth, required this.supportedStandards, + this.possibleChannels = const [], + this.isDfsEnabled = false, this.accessPoints = const [], }); @@ -48,37 +72,62 @@ class WifiRadioUIModel extends Equatable { } @override - List get props => [ - instancePath, - band, - enable, - transmitPower, - maxBitRate, - channel, - autoChannelEnable, - channelBandwidth, - supportedStandards, - accessPoints, - ]; + String get diagnosticName => 'WifiRadioUIModel'; + + @override + Map get namedProps => { + 'instancePath': instancePath, + 'band': band, + 'enable': enable, + 'transmitPower': transmitPower, + 'maxBitRate': maxBitRate, + 'channel': channel, + 'autoChannelEnable': autoChannelEnable, + 'channelBandwidth': channelBandwidth, + 'supportedStandards': supportedStandards, + 'possibleChannels': possibleChannels, + 'isDfsEnabled': isDfsEnabled, + 'accessPoints': accessPoints, + }; } /// Presentation Layer Model for a WiFi access point. -class WifiAccessPointUIModel extends Equatable { +class WifiAccessPointUIModel extends Equatable with DiagnosticLoggable { final bool enable; final String ssidName; // Resolved from SSID reference final String securityMode; final String encryptionMode; final bool isGuest; + /// TR-181 instance path of the AccessPoint (e.g. Device.WiFi.AccessPoint.2.). + /// Needed by the Dashboard per-network toggle to mutate AccessPoint.Enable. + final String accessPointInstancePath; + + /// TR-181 instance path of the SSID this AP serves (e.g. Device.WiFi.SSID.2.). + /// Needed by the Dashboard per-network toggle to mutate SSID.Enable. + final String ssidInstancePath; + const WifiAccessPointUIModel({ required this.enable, required this.ssidName, required this.securityMode, required this.encryptionMode, this.isGuest = false, + this.accessPointInstancePath = '', + this.ssidInstancePath = '', }); @override - List get props => - [enable, ssidName, securityMode, encryptionMode, isGuest]; + String get diagnosticName => 'WifiAccessPointUIModel'; + + @override + Map get namedProps => { + 'enable': enable, + 'ssidName': ssidName, + 'securityMode': securityMode, + 'encryptionMode': encryptionMode, + 'isGuest': isGuest, + 'accessPointInstancePath': accessPointInstancePath, + 'ssidInstancePath': ssidInstancePath, + }; } diff --git a/lib/page/_shared/providers/device_analytics_persistence.dart b/lib/page/_shared/providers/device_analytics_persistence.dart index 9aeeacdd4..4acb5114f 100644 --- a/lib/page/_shared/providers/device_analytics_persistence.dart +++ b/lib/page/_shared/providers/device_analytics_persistence.dart @@ -2,21 +2,37 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../models/device_analytics_state.dart'; -const _prefsKey = 'usp_device_analytics'; +const _prefsKeyPrefix = 'usp_device_analytics'; + +/// Build storage key scoped to a specific router by serial number. +/// +/// This ensures analytics history from different routers don't mix. +String _buildKey(String? serialNumber) { + if (serialNumber == null || serialNumber.isEmpty) { + return _prefsKeyPrefix; + } + return '${_prefsKeyPrefix}_$serialNumber'; +} /// Save hourly history + known MACs to SharedPreferences. -Future saveDeviceAnalytics(DeviceAnalyticsState state) async { +/// +/// [serialNumber] scopes the data to a specific router (master SN). +Future saveDeviceAnalytics( + DeviceAnalyticsState state, { + String? serialNumber, +}) async { final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_prefsKey, state.toJsonString()); + await prefs.setString(_buildKey(serialNumber), state.toJsonString()); } /// Load hourly history from SharedPreferences. /// +/// [serialNumber] scopes the data to a specific router (master SN). /// Returns a state with only historical data (no current distribution). /// Automatically prunes entries older than 24 hours. -Future loadDeviceAnalytics() async { +Future loadDeviceAnalytics({String? serialNumber}) async { final prefs = await SharedPreferences.getInstance(); - final json = prefs.getString(_prefsKey); + final json = prefs.getString(_buildKey(serialNumber)); if (json == null) return const DeviceAnalyticsState(); final loaded = DeviceAnalyticsState.fromJsonString(json); diff --git a/lib/page/_shared/providers/usp_device_analytics_notifier.dart b/lib/page/_shared/providers/usp_device_analytics_notifier.dart index bb6ecaac6..033a75476 100644 --- a/lib/page/_shared/providers/usp_device_analytics_notifier.dart +++ b/lib/page/_shared/providers/usp_device_analytics_notifier.dart @@ -1,8 +1,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:privacy_gui/core/utils/logger.dart'; import 'package:privacy_gui/page/_shared/models/device_analytics_state.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; import 'package:privacy_gui/page/_shared/providers/device_analytics_persistence.dart'; +import 'package:privacy_gui/page/admin/providers/system_info_data_provider.dart'; import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; /// Device connection analytics provider — computes distributions, hourly @@ -16,24 +17,34 @@ final uspDeviceAnalyticsProvider = ); class UspDeviceAnalyticsNotifier extends Notifier { + /// Cached serial number for persistence key scoping. + String? _serialNumber; + + /// Whether persisted history has been loaded. + bool _historyLoaded = false; + @override DeviceAnalyticsState build() { - // Load persisted history on init - _loadPersistedHistory(); + // Reset instance state on rebuild (e.g., after invalidate) + _historyLoaded = false; + _serialNumber = null; // Listen to device data changes for future updates ref.listen(devicesDataProvider, (previous, next) { final data = next.valueOrNull; if (data == null) return; - _onDashboardUpdated(data.deviceModels); + // Use clientDevices to exclude mesh nodes (master/slave) + _onDashboardUpdated(data.clientDevices); }); - // Process current device data after build() completes - // (same pattern as UspTrafficMonitorNotifier) - Future.microtask(() { + // Wait for systemInfoDataProvider to load, then load persisted history + // and process current device data + Future.microtask(() async { + await _loadPersistedHistory(); + final data = ref.read(devicesDataProvider).valueOrNull; if (data != null) { - _onDashboardUpdated(data.deviceModels); + _onDashboardUpdated(data.clientDevices); } }); @@ -41,22 +52,79 @@ class UspDeviceAnalyticsNotifier extends Notifier { } Future _loadPersistedHistory() async { + if (_historyLoaded) return; + try { - final persisted = await loadDeviceAnalytics(); + // Wait for systemInfoDataProvider to have data + final sysInfo = ref.read(systemInfoDataProvider).valueOrNull; + if (sysInfo == null) { + // Try to wait for it + try { + await ref + .read(systemInfoDataProvider.future) + .timeout(const Duration(seconds: 3)); + } catch (_) { + // Timeout or error — proceed without SN (uses legacy key) + } + } + + _serialNumber = + ref.read(systemInfoDataProvider).valueOrNull?.model.serialNumber; + + final persisted = await loadDeviceAnalytics(serialNumber: _serialNumber); if (persisted.hourlyHistory.isNotEmpty) { + // Get router MACs to filter out from persisted history + // (legacy data may contain mesh node MACs before the fix) + final routerMacs = _getRouterMacs(); + + // Clean router MACs from persisted data + final cleanedHistory = persisted.hourlyHistory + .map((h) => HourlyAggregate( + hour: h.hour, + wifiCount: h.wifiCount, + wiredCount: h.wiredCount, + activeMacs: h.activeMacs + .where((m) => !routerMacs.contains(m)) + .toSet(), + )) + .toList(); + + final cleanedMacs = persisted.allKnownMacs + .where((m) => !routerMacs.contains(m)) + .toSet(); + state = state.copyWith( - hourlyHistory: persisted.hourlyHistory, - allKnownMacs: persisted.allKnownMacs, + hourlyHistory: cleanedHistory, + allKnownMacs: cleanedMacs, macDisplayNames: persisted.macDisplayNames, ); } + + _historyLoaded = true; } catch (e) { logger .w('[USP][Monitor][Analytics]: Failed to load persisted history: $e'); + // Mark as loaded even on failure so subsequent _persistState() calls are + // not permanently gated by `if (!_historyLoaded) return`. Otherwise a + // one-off load error (e.g. SharedPreferences cold-start race, corrupted + // JSON) would silently drop every future write for this provider's life. + _historyLoaded = true; } } - void _onDashboardUpdated(List devices) { + /// Returns the set of router MACs (master + slave nodes). + /// Used to clean legacy persisted data that may contain mesh node MACs. + Set _getRouterMacs() { + final data = ref.read(devicesDataProvider).valueOrNull; + if (data == null) return {}; + final nodeMacs = {data.master.deviceId}; + for (final slave in data.slaves) { + nodeMacs.add(slave.deviceId); + } + return nodeMacs; + } + + void _onDashboardUpdated(List devices) { // 1. Compute current distribution final distribution = _computeDistribution(devices); @@ -97,7 +165,7 @@ class UspDeviceAnalyticsNotifier extends Notifier { final cutoff = now.subtract(Duration(hours: DeviceAnalyticsState.maxHours)); history = history.where((h) => h.hour.isAfter(cutoff)).toList(); - // Rebuild allKnownMacs from history + // Rebuild allKnownMacs from history (clientDevices already excludes mesh nodes) final allMacs = {}; for (final h in history) { allMacs.addAll(h.activeMacs); @@ -114,7 +182,7 @@ class UspDeviceAnalyticsNotifier extends Notifier { _persistState(); } - DeviceDistribution _computeDistribution(List devices) { + DeviceDistribution _computeDistribution(List devices) { final online = devices.where((d) => d.isActive).toList(); final offline = devices.where((d) => !d.isActive).toList(); @@ -122,14 +190,14 @@ class UspDeviceAnalyticsNotifier extends Notifier { final wifiDevices = online.where((d) => d.isWifi).toList(); final wiredDevices = online.where((d) => !d.isWifi).toList(); - // Band distribution (online WiFi only + wired category) - final bandDist = {}; - for (final d in wifiDevices) { - final band = d.band ?? 'Unknown'; - bandDist[band] = (bandDist[band] ?? 0) + 1; - } - if (wiredDevices.isNotEmpty) { - bandDist['Wired'] = wiredDevices.length; + // Category distribution (online only) — see _getDeviceCategory: + // - WiFi with a band: show band (2.4GHz, 5GHz, 6GHz) + // - Wired: show "Wired" + // - WiFi without a band (e.g. slave clients pending #1118): show node name + final categoryDist = {}; + for (final d in online) { + final category = _getDeviceCategory(d); + categoryDist[category] = (categoryDist[category] ?? 0) + 1; } // Signal level distribution (online WiFi only) @@ -139,17 +207,20 @@ class UspDeviceAnalyticsNotifier extends Notifier { signalDist[level] = (signalDist[level] ?? 0) + 1; } - // Average signal quality per band (for radar chart) - final bandQualitySum = {}; - final bandQualityCount = {}; + // Average signal quality per category (WiFi devices only) + final categoryQualitySum = {}; + final categoryQualityCount = {}; for (final d in wifiDevices) { - final band = d.band ?? 'Unknown'; - bandQualitySum[band] = (bandQualitySum[band] ?? 0) + d.signalQuality; - bandQualityCount[band] = (bandQualityCount[band] ?? 0) + 1; + final category = _getDeviceCategory(d); + categoryQualitySum[category] = + (categoryQualitySum[category] ?? 0) + d.signalQuality; + categoryQualityCount[category] = + (categoryQualityCount[category] ?? 0) + 1; } final bandSignalQuality = {}; - for (final band in bandQualitySum.keys) { - bandSignalQuality[band] = bandQualitySum[band]! / bandQualityCount[band]!; + for (final cat in categoryQualitySum.keys) { + bandSignalQuality[cat] = + categoryQualitySum[cat]! / categoryQualityCount[cat]!; } return DeviceDistribution( @@ -157,15 +228,44 @@ class UspDeviceAnalyticsNotifier extends Notifier { wiredCount: wiredDevices.length, onlineCount: online.length, offlineCount: offline.length, - bandDistribution: bandDist, + bandDistribution: categoryDist, signalLevelDistribution: signalDist, bandSignalQuality: bandSignalQuality, ); } + /// Determines the display category for a device. + /// + /// - WiFi client with a resolved band: the band (2.4GHz / 5GHz / 6GHz) + /// - Wired client: "Wired" + /// - WiFi client without a band: the connected node name, else "WiFi" + /// + /// Band takes priority over the node grouping, because a master WiFi client + /// keeps a valid band in a mesh too (it comes from the local + /// `WiFi.AccessPoint` chain, not DataElements). Keying off `parentNodeId` + /// alone was wrong: `MeshTopologyBuilder` maps EVERY node's associated STAs — + /// including the master's own clients — into `clientToNodeMap`, so master + /// clients also get a non-null `parentNodeId` on a mesh, which previously + /// collapsed their band under the gateway name. + /// + /// Slave WiFi clients currently have no band (DataElements band resolution is + /// pending — see #1118), so they fall through to the node-name grouping. + String _getDeviceCategory(ClientDevice d) { + if (d.isWifi) { + final band = d.band; + if (band != null && band.isNotEmpty) return band; + // WiFi client without a resolved band: group under its node. + return d.parentNodeName ?? 'WiFi'; + } + return 'Wired'; + } + Future _persistState() async { + // Don't persist before history is loaded — _serialNumber isn't set yet, + // and we'd write to the legacy key instead of the scoped key. + if (!_historyLoaded) return; try { - await saveDeviceAnalytics(state); + await saveDeviceAnalytics(state, serialNumber: _serialNumber); } catch (e) { logger.w('[USP][Monitor][Analytics]: Failed to persist: $e'); } diff --git a/lib/page/_shared/services/usp_pdf_service.dart b/lib/page/_shared/services/usp_pdf_service.dart index 6d9ff25f6..838f6cde2 100644 --- a/lib/page/_shared/services/usp_pdf_service.dart +++ b/lib/page/_shared/services/usp_pdf_service.dart @@ -352,9 +352,9 @@ class UspPdfService { // =========================================================================== static List _buildDevices(PdfReportData data) { - final devices = data.deviceModels ?? []; - final online = devices.where((d) => d.isActive).toList(); - final offline = devices.where((d) => !d.isActive).toList(); + final devices = data.clientDevices ?? []; + final online = devices.where((d) => d.isOnline).toList(); + final offline = devices.where((d) => !d.isOnline).toList(); final widgets = [ _sectionTitle('Connected Devices (${online.length} online / ' @@ -421,13 +421,13 @@ class UspPdfService { cellStyle: const pw.TextStyle(fontSize: 8), cellPadding: const pw.EdgeInsets.symmetric(horizontal: 4, vertical: 2), headerDecoration: const pw.BoxDecoration(color: PdfColors.grey200), - headers: ['Name', 'MAC', 'IP', 'Active', 'Lease'], + headers: ['Name', 'MAC', 'IP', 'Online', 'Lease'], data: clients .map((c) => [ c.displayName, c.mac, c.ip, - c.active ? 'Yes' : 'No', + (c.isOnline ?? false) ? 'Yes' : 'No', c.leaseTimeFormatted.isNotEmpty ? c.leaseTimeFormatted : '—', ]) .toList(), @@ -845,7 +845,7 @@ class UspPdfService { } static List _buildMeshTopology(PdfReportData data) { - final nodes = data.nodeModels ?? []; + final nodes = data.nodes ?? []; if (nodes.isEmpty) return []; return [ _sectionTitle('Mesh Topology (${nodes.length} nodes)'), @@ -859,10 +859,10 @@ class UspPdfService { data: nodes .map((n) => [ n.displayName, - n.roleLabel, + n.isMaster ? 'Master' : 'Extender', n.model, n.softwareVersion, - '${n.connectedDeviceCount}', + '${n.connectedClients.length}', ]) .toList(), ), diff --git a/lib/page/_shared/services/usp_system_monitor_service.dart b/lib/page/_shared/services/usp_system_monitor_service.dart index 8e5c7fe34..18c4ebd20 100644 --- a/lib/page/_shared/services/usp_system_monitor_service.dart +++ b/lib/page/_shared/services/usp_system_monitor_service.dart @@ -46,6 +46,7 @@ class UspSystemMonitorService { memoryPercent: memPercent, totalMemoryKb: info.totalMemory, freeMemoryKb: info.freeMemory, + uptimeSeconds: info.uptime, ); } } diff --git a/lib/page/_shared/utils/mesh_network_builder.dart b/lib/page/_shared/utils/mesh_network_builder.dart new file mode 100644 index 000000000..56e13afbf --- /dev/null +++ b/lib/page/_shared/utils/mesh_network_builder.dart @@ -0,0 +1,488 @@ +import 'package:privacy_gui/core/utils/logger.dart'; +import 'package:privacy_gui/generated/connected_devices.g.dart'; +import 'package:privacy_gui/page/_shared/models/backhaul_info.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_network.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_topology_info.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; +import 'package:privacy_gui/page/_shared/models/system_info_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/wifi_client_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/wifi_connection_info.dart'; +import 'package:privacy_gui/page/_shared/models/client_connection_detail.dart'; + +/// Builds [MeshNetwork] from raw data sources. +/// +/// Transforms Hosts (ConnectedDevices), WiFi enrichment, and DataElements +/// into the unified MeshNetwork architecture. +class MeshNetworkBuilder { + MeshNetworkBuilder._(); + + /// Builds a [MeshNetwork] from the various data sources. + /// + /// Data sources: + /// - [connectedDevices]: Hosts.Host table (all devices + mesh nodes) + /// - [wifiClientMap]: WiFi STA enrichment (signal, rate for master clients) + /// - [connectionDetailMap]: band/SSID info + /// - [meshTopology]: DataElements (clientToNodeMap, clientSignalMap, nodes) + /// - [gatewayName]: Fallback name for master node + /// - [systemInfo]: Gateway device info (model, firmware, etc.) + static MeshNetwork build({ + required ConnectedDevices connectedDevices, + required Map wifiClientMap, + required Map connectionDetailMap, + required MeshTopologyInfo meshTopology, + required String gatewayName, + SystemInfoUIModel? systemInfo, + }) { + // 1. Separate mesh nodes and client devices + final meshDevices = []; + final clientHostDevices = []; + + for (final d in connectedDevices.items) { + if (d.deviceRole == 'master' || d.deviceRole == 'slave') { + meshDevices.add(d); + } else if (d.interface_.isNotEmpty || d.isActive) { + clientHostDevices.add(d); + } + } + + // 2. Build node display name map (Hosts hostname → DataElements node ID) + final nodeDisplayNameMap = + _buildNodeDisplayNameMap(connectedDevices, meshTopology); + + // 3. Build all ClientDevice models + final allBuiltClients = clientHostDevices + .map((d) => _buildClientDevice( + device: d, + wifiClientMap: wifiClientMap, + connectionDetailMap: connectionDetailMap, + meshTopology: meshTopology, + gatewayName: gatewayName, + nodeDisplayNameMap: nodeDisplayNameMap, + )) + .toList(); + + // 4. Apply hostname grouping (merge multi-interface devices) + final groupedClients = _groupByHostname(allBuiltClients); + + // 5. Group clients by parentNodeId + final clientsByNodeId = >{}; + for (final client in groupedClients) { + final nodeId = client.parentNodeId; + (clientsByNodeId[nodeId] ??= []).add(client); + } + + // 6. Build MasterNode + final masterDevice = + meshDevices.where((d) => d.deviceRole == 'master').firstOrNull; + final masterMeshInfo = meshTopology.nodes.master; + final masterNodeId = masterDevice?.macAddress.trim().toUpperCase() ?? + masterMeshInfo?.deviceId ?? + 'GATEWAY'; + + // Clients for master: those with null parentNodeId or matching master ID + final masterClients = []; + final nullParentClients = clientsByNodeId[null] ?? []; + masterClients.addAll(nullParentClients); + if (clientsByNodeId.containsKey(masterNodeId)) { + masterClients.addAll(clientsByNodeId[masterNodeId]!); + } + // Also match by DataElements master ID + if (masterMeshInfo != null && + masterMeshInfo.deviceId != masterNodeId && + clientsByNodeId.containsKey(masterMeshInfo.deviceId)) { + masterClients.addAll(clientsByNodeId[masterMeshInfo.deviceId]!); + } + + final master = _buildMasterNode( + masterDevice: masterDevice, + masterMeshInfo: masterMeshInfo, + systemInfo: systemInfo, + gatewayName: gatewayName, + connectedClients: masterClients, + ); + + // Compute master displayName for patching clients + final masterDisplayName = master.displayName; + + // Patch master's connected clients with parentNodeName + final patchedMasterClients = masterClients + .map((c) => c.copyWith(parentNodeName: masterDisplayName)) + .toList(); + final patchedMaster = MasterNode( + deviceId: master.deviceId, + dataElementsId: master.dataElementsId, + friendlyName: master.friendlyName, + hostName: master.hostName, + model: master.model, + manufacturer: master.manufacturer, + serialNumber: master.serialNumber, + softwareVersion: master.softwareVersion, + ipAddress: master.ipAddress, + ipv6Addresses: master.ipv6Addresses, + instancePath: master.instancePath, + connectedClients: patchedMasterClients, + hostsDeviceId: master.hostsDeviceId, + ); + + // 7. Build SlaveNodes + final slaves = meshDevices.where((d) => d.deviceRole == 'slave').map((d) { + final slaveMeshInfo = _findMatchingMeshNode(d, meshTopology.nodes.slaves); + final slaveNodeId = d.macAddress.trim().toUpperCase(); + + // Clients for this slave + final slaveClients = []; + if (clientsByNodeId.containsKey(slaveNodeId)) { + slaveClients.addAll(clientsByNodeId[slaveNodeId]!); + } + if (slaveMeshInfo != null && + slaveMeshInfo.deviceId != slaveNodeId && + clientsByNodeId.containsKey(slaveMeshInfo.deviceId)) { + slaveClients.addAll(clientsByNodeId[slaveMeshInfo.deviceId]!); + } + + final slave = _buildSlaveNode( + slaveDevice: d, + slaveMeshInfo: slaveMeshInfo, + connectedClients: slaveClients, + ); + + // Patch slave's connected clients with parentNodeName + final slaveDisplayName = slave.displayName; + final patchedSlaveClients = slaveClients + .map((c) => c.copyWith(parentNodeName: slaveDisplayName)) + .toList(); + return SlaveNode( + deviceId: slave.deviceId, + dataElementsId: slave.dataElementsId, + friendlyName: slave.friendlyName, + hostName: slave.hostName, + model: slave.model, + manufacturer: slave.manufacturer, + serialNumber: slave.serialNumber, + softwareVersion: slave.softwareVersion, + ipAddress: slave.ipAddress, + ipv6Addresses: slave.ipv6Addresses, + instancePath: slave.instancePath, + connectedClients: patchedSlaveClients, + backhaul: slave.backhaul, + ); + }).toList(); + + // 8. Find unassigned clients (parentNodeId doesn't match any known node) + final assignedNodeIds = { + masterNodeId, + if (masterMeshInfo != null) masterMeshInfo.deviceId, + ...slaves.map((s) => s.deviceId), + ...slaves.map((s) => s.dataElementsId).whereType(), + }; + final unassigned = clientsByNodeId.entries + .where((e) => e.key != null && !assignedNodeIds.contains(e.key)) + .expand((e) => e.value) + .toList(); + + return MeshNetwork( + master: patchedMaster, + slaves: slaves, + unassignedClients: unassigned, + ); + } + + // --------------------------------------------------------------------------- + // Private: Node display name map + // --------------------------------------------------------------------------- + + static Map _buildNodeDisplayNameMap( + ConnectedDevices devices, + MeshTopologyInfo meshTopology, + ) { + final map = {}; + for (final d in devices.items) { + // Include both master and slave nodes + if (d.deviceRole != 'master' && d.deviceRole != 'slave') continue; + + final displayName = (d.friendlyName?.isNotEmpty == true) + ? d.friendlyName! + : (d.hostName.isNotEmpty ? d.hostName : null); + if (displayName == null) continue; + + // Match via embedded MAC in Hosts DeviceID + final hostsDeviceId = d.deviceId?.toUpperCase().replaceAll('-', '') ?? ''; + if (hostsDeviceId.length >= 12) { + final embeddedMac = hostsDeviceId.substring(hostsDeviceId.length - 12); + for (final node in meshTopology.nodes) { + final nodeIdNormalized = + node.deviceId.toUpperCase().replaceAll(':', ''); + if (nodeIdNormalized == embeddedMac) { + map[node.deviceId] = displayName; + break; + } + } + } + + // For master, also try matching via MAC address directly + if (d.deviceRole == 'master') { + final masterMac = d.macAddress.trim().toUpperCase(); + if (masterMac.isNotEmpty && !map.containsKey(masterMac)) { + map[masterMac] = displayName; + } + } + } + return map; + } + + // --------------------------------------------------------------------------- + // Private: ClientDevice builder + // --------------------------------------------------------------------------- + + static ClientDevice _buildClientDevice({ + required ConnectedDevice device, + required Map wifiClientMap, + required Map connectionDetailMap, + required MeshTopologyInfo meshTopology, + required String gatewayName, + required Map nodeDisplayNameMap, + }) { + final mac = device.macAddress.trim().toUpperCase(); + + // Determine WiFi via Layer1Interface or InterfaceType + final interfaceType = device.interfaceType?.toLowerCase() ?? ''; + final isWifi = device.interface_.toLowerCase().contains('wifi') || + interfaceType.contains('wi-fi') || + interfaceType.contains('wifi'); + + final wifiClient = wifiClientMap[mac]; + final detail = connectionDetailMap[mac]; + + // Resolve parent node + String? parentNodeId; + String? parentNodeName; + if (meshTopology.isEmpty) { + // Non-mesh: all active devices are on the gateway + if (device.isActive) parentNodeName = gatewayName; + } else { + parentNodeId = meshTopology.clientToNodeMap[mac]; + if (parentNodeId != null) { + // Try display name map first (friendlyName or hostName from Hosts) + parentNodeName = nodeDisplayNameMap[parentNodeId]; + if (parentNodeName == null) { + // Fallback: use model name from DataElements + final matchingNode = meshTopology.nodes + .where((n) => n.deviceId == parentNodeId) + .firstOrNull; + parentNodeName = matchingNode?.model.isNotEmpty == true + ? matchingNode!.model + : gatewayName; + } + } + } + + // Build WiFi info if applicable + WifiConnectionInfo? wifi; + if (isWifi) { + final signalStrength = device.signalStrength ?? + wifiClient?.signalStrength ?? + meshTopology.clientSignalMap[mac]; + // Fallback to DataElements band/SSID for slave node clients. + // ClientConnectionDetail.band/ssidName are non-nullable Strings that are + // '' when AP→SSID→radio resolution fails, so treat empty as absent — + // otherwise the empty string would mask the DataElements fallback value. + final bandSsid = meshTopology.clientBandSsidMap[mac]; + wifi = WifiConnectionInfo( + signalStrength: signalStrength, + band: _nonEmpty(detail?.band) ?? bandSsid?.band, + ssidName: _nonEmpty(detail?.ssidName) ?? bandSsid?.ssid, + downlinkRate: + device.lastDataDownlinkRate ?? wifiClient?.lastDataDownlinkRate, + uplinkRate: device.lastDataUplinkRate ?? wifiClient?.lastDataUplinkRate, + ); + } + + return ClientDevice( + mac: mac, + ip: device.ipAddress, + hostName: device.hostName, + friendlyName: device.friendlyName, + isActive: device.isActive, + ipv6Addresses: device.ipv6Addresses + .map((e) => e.address) + .where((a) => a.isNotEmpty) + .toList(), + layer1Interface: device.interface_, + connectionType: isWifi ? ConnectionType.wifi : ConnectionType.wired, + wifi: wifi, + parentNodeId: parentNodeId, + parentNodeName: parentNodeName, + manufacturer: device.manufacturer, + modelName: device.modelName, + operatingSystem: device.operatingSystem, + hostsDeviceId: device.deviceId, + ); + } + + // --------------------------------------------------------------------------- + // Private: Hostname grouping + // --------------------------------------------------------------------------- + + /// Returns [s] if it is non-null and non-empty, otherwise null. + /// Used so an empty String from a non-nullable source doesn't mask a `??` + /// fallback to another source. + static String? _nonEmpty(String? s) => (s != null && s.isNotEmpty) ? s : null; + + static String _normalizeHostname(String hostname) { + var normalized = hostname.trim().toLowerCase(); + if (normalized.isEmpty) return ''; + final mdnsSuffixIndex = normalized.indexOf('._'); + if (mdnsSuffixIndex > 0) { + normalized = normalized.substring(0, mdnsSuffixIndex); + } + return normalized; + } + + static List _groupByHostname(List clients) { + final grouped = >{}; + final ungrouped = []; + + for (final client in clients) { + final hostname = _normalizeHostname(client.hostName); + if (hostname.isEmpty) { + ungrouped.add(client); + } else { + grouped.putIfAbsent(hostname, () => []).add(client); + } + } + + final result = []; + for (final devices in grouped.values) { + if (devices.length == 1) { + result.add(devices.first); + } else { + result.add(_mergeClientsByHostname(devices)); + } + } + result.addAll(ungrouped); + return result; + } + + static ClientDevice _mergeClientsByHostname(List devices) { + final sorted = List.from(devices) + ..sort((a, b) { + if (a.isActive != b.isActive) return a.isActive ? -1 : 1; + if (a.isWifi != b.isWifi) return a.isWifi ? -1 : 1; + return 0; + }); + + final primary = sorted.first; + final additional = sorted + .skip(1) + .map((d) => ClientInterfaceInfo( + mac: d.mac, + ip: d.ip, + connectionType: d.connectionType, + isActive: d.isActive, + layer1Interface: d.layer1Interface, + wifi: d.wifi, + )) + .toList(); + + logger.d('[MeshNetworkBuilder]: Merged ${devices.length} interfaces for ' + 'hostname="${primary.hostName}" — primary=${primary.mac}'); + + return primary.copyWith(additionalInterfaces: additional); + } + + // --------------------------------------------------------------------------- + // Private: MasterNode builder + // --------------------------------------------------------------------------- + + static MasterNode _buildMasterNode({ + required ConnectedDevice? masterDevice, + required MasterNode? masterMeshInfo, + required SystemInfoUIModel? systemInfo, + required String gatewayName, + required List connectedClients, + }) { + final deviceId = masterDevice?.macAddress.trim().toUpperCase() ?? + masterMeshInfo?.deviceId ?? + 'GATEWAY'; + + return MasterNode( + deviceId: deviceId, + dataElementsId: masterMeshInfo?.deviceId, + friendlyName: masterDevice?.friendlyName, + hostName: masterDevice?.hostName ?? gatewayName, + model: masterMeshInfo?.model ?? systemInfo?.modelName ?? '', + manufacturer: + masterMeshInfo?.manufacturer ?? systemInfo?.manufacturer ?? '', + serialNumber: + masterMeshInfo?.serialNumber ?? systemInfo?.serialNumber ?? '', + softwareVersion: + masterMeshInfo?.softwareVersion ?? systemInfo?.softwareVersion ?? '', + ipAddress: masterDevice?.ipAddress, + ipv6Addresses: masterDevice?.ipv6Addresses + .map((e) => e.address) + .where((a) => a.isNotEmpty) + .toList() ?? + [], + instancePath: masterMeshInfo?.instancePath, + connectedClients: connectedClients, + hostsDeviceId: masterDevice?.deviceId, + ); + } + + // --------------------------------------------------------------------------- + // Private: SlaveNode builder + // --------------------------------------------------------------------------- + + static SlaveNode _buildSlaveNode({ + required ConnectedDevice slaveDevice, + required SlaveNode? slaveMeshInfo, + required List connectedClients, + }) { + final deviceId = slaveDevice.macAddress.trim().toUpperCase(); + + final backhaul = + slaveMeshInfo?.backhaul ?? const BackhaulInfo(mediaType: ''); + + return SlaveNode( + deviceId: deviceId, + dataElementsId: slaveMeshInfo?.deviceId, + friendlyName: slaveDevice.friendlyName, + hostName: slaveDevice.hostName, + model: slaveMeshInfo?.model ?? slaveDevice.modelName ?? '', + manufacturer: + slaveMeshInfo?.manufacturer ?? slaveDevice.manufacturer ?? '', + serialNumber: slaveMeshInfo?.serialNumber ?? '', + softwareVersion: slaveMeshInfo?.softwareVersion ?? '', + ipAddress: + slaveDevice.ipAddress.isNotEmpty ? slaveDevice.ipAddress : null, + ipv6Addresses: slaveDevice.ipv6Addresses + .map((e) => e.address) + .where((a) => a.isNotEmpty) + .toList(), + instancePath: slaveMeshInfo?.instancePath, + connectedClients: connectedClients, + backhaul: backhaul, + ); + } + + // --------------------------------------------------------------------------- + // Private: Mesh node matching + // --------------------------------------------------------------------------- + + static SlaveNode? _findMatchingMeshNode( + ConnectedDevice device, + List meshSlaves, + ) { + final hostsDeviceId = + device.deviceId?.toUpperCase().replaceAll('-', '') ?? ''; + if (hostsDeviceId.length < 12) return null; + + final embeddedMac = hostsDeviceId.substring(hostsDeviceId.length - 12); + + return meshSlaves.where((n) { + final nodeIdNormalized = n.deviceId.toUpperCase().replaceAll(':', ''); + return nodeIdNormalized == embeddedMac; + }).firstOrNull; + } +} diff --git a/lib/page/_shared/utils/mesh_topology_builder.dart b/lib/page/_shared/utils/mesh_topology_builder.dart index 195032254..a17165b04 100644 --- a/lib/page/_shared/utils/mesh_topology_builder.dart +++ b/lib/page/_shared/utils/mesh_topology_builder.dart @@ -1,6 +1,7 @@ import 'package:privacy_gui/generated/data_elements_network.g.dart'; +import 'package:privacy_gui/page/_shared/models/backhaul_info.dart'; import 'package:privacy_gui/page/_shared/models/mesh_topology_info.dart'; -import 'package:privacy_gui/page/topology/models/node_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; import 'package:privacy_gui/page/_shared/components/wifi_ui.dart'; /// Builds [MeshTopologyInfo] from DataElements network data. @@ -15,26 +16,48 @@ class MeshTopologyBuilder { /// Extracts mesh nodes and builds client MAC → node ID mapping /// for determining which mesh node each client device is connected to. /// + /// [bssidToBandMap] is optional mapping of BSSID (uppercase) → band string + /// (e.g., "2.4GHz", "5GHz"). When provided, allows extracting band info + /// for clients connected to any node (including slave nodes). + /// /// Set [includeBackhaulStats] to true to include backhaul signal strength /// and uplink rate (requires DataElements backhaul stats to be available). static MeshTopologyInfo build( DataElementsNetwork network, { + Map bssidToBandMap = const {}, bool includeBackhaulStats = true, }) { - final nodes = []; + final nodes = []; final clientToNodeMap = {}; + final clientSignalMap = {}; + final clientBandSsidMap = {}; for (final node in network.items) { final rawId = node.id.trim().toUpperCase(); final nodeDeviceId = rawId.isNotEmpty ? rawId : node.instancePath; - // Build client MAC → node ID mapping from station list + // Build client MAC → node ID mapping, signal strength, and band/SSID for (final radio in node.radios) { for (final bss in radio.bssList) { + // Resolve band from BSSID → band mapping + final bssidUpper = bss.bssid.trim().toUpperCase(); + final band = bssidToBandMap[bssidUpper] ?? ''; + final ssid = bss.ssid.trim(); + for (final sta in bss.stations) { final mac = sta.macAddress.trim(); if (mac.isNotEmpty && nodeDeviceId.isNotEmpty) { - clientToNodeMap[mac.toUpperCase()] = nodeDeviceId; + final upperMac = mac.toUpperCase(); + clientToNodeMap[upperMac] = nodeDeviceId; + // DataElements STA.SignalStrength is RCPI (0-220), convert to RSSI + final rssi = rcpiToRssi(sta.signalStrength); + if (rssi != null) { + clientSignalMap[upperMac] = rssi; + } + // Store band + SSID for this client + if (band.isNotEmpty || ssid.isNotEmpty) { + clientBandSsidMap[upperMac] = (band: band, ssid: ssid); + } } } } @@ -42,52 +65,67 @@ class MeshTopologyBuilder { int? backhaulSignalStrength; int? backhaulUplinkRate; + int? backhaulDownlinkRate; if (includeBackhaulStats) { backhaulSignalStrength = rcpiToRssi(node.backhaulStatsSignalStrength); if (node.backhaulStatsLastDataUplinkRate > 0) { backhaulUplinkRate = node.backhaulStatsLastDataUplinkRate; } + if (node.backhaulStatsLastDataDownlinkRate > 0) { + backhaulDownlinkRate = node.backhaulStatsLastDataDownlinkRate; + } } // Master node = no backhaul parent (backhaulAlId is empty) final isMaster = node.backhaulAlId.trim().isEmpty; - // Downlink rate (kbps) - int? backhaulDownlinkRate; - if (includeBackhaulStats && node.backhaulStatsLastDataDownlinkRate > 0) { - backhaulDownlinkRate = node.backhaulStatsLastDataDownlinkRate; + if (isMaster) { + nodes.add(MasterNode( + deviceId: nodeDeviceId, + model: node.manufacturerModel.trim(), + manufacturer: node.manufacturer.trim(), + serialNumber: node.serialNumber.trim(), + softwareVersion: node.softwareVersion.trim(), + instancePath: node.instancePath, + )); + } else { + nodes.add(SlaveNode( + deviceId: nodeDeviceId, + model: node.manufacturerModel.trim(), + manufacturer: node.manufacturer.trim(), + serialNumber: node.serialNumber.trim(), + softwareVersion: node.softwareVersion.trim(), + instancePath: node.instancePath, + backhaul: BackhaulInfo( + mediaType: node.backhaulMediaType.trim(), + linkType: node.backhaulLinkType.trim().isNotEmpty + ? node.backhaulLinkType.trim() + : null, + phyRate: node.backhaulPhyRate, + signalStrength: backhaulSignalStrength, + uplinkRate: backhaulUplinkRate, + downlinkRate: backhaulDownlinkRate, + parentNodeId: node.backhaulBackhaulDeviceId.trim().isNotEmpty + ? node.backhaulBackhaulDeviceId.trim() + : null, + parentBssid: node.backhaulMacAddressMultiAp.trim().isNotEmpty + ? node.backhaulMacAddressMultiAp.trim() + : null, + lastContactTime: node.multiApLastContactTime.trim().isNotEmpty + ? node.multiApLastContactTime.trim() + : null, + backhaulAlId: node.backhaulAlId.trim(), + backhaulMacAddress: node.backhaulMacAddress.trim(), + ), + )); } - - nodes.add(NodeUIModel( - deviceId: nodeDeviceId, - model: node.manufacturerModel.trim(), - manufacturer: node.manufacturer.trim(), - serialNumber: node.serialNumber.trim(), - softwareVersion: node.softwareVersion.trim(), - isMaster: isMaster, - backhaulMediaType: node.backhaulMediaType.trim(), - backhaulPhyRate: node.backhaulPhyRate, - backhaulSignalStrength: backhaulSignalStrength, - backhaulUplinkRate: backhaulUplinkRate, - instancePath: node.instancePath, - backhaulAlId: node.backhaulAlId.trim(), - backhaulMacAddress: node.backhaulMacAddress.trim(), - backhaulLinkType: node.backhaulLinkType.trim().isNotEmpty - ? node.backhaulLinkType.trim() - : null, - backhaulDownlinkRate: backhaulDownlinkRate, - backhaulParentDeviceId: node.backhaulBackhaulDeviceId.trim().isNotEmpty - ? node.backhaulBackhaulDeviceId.trim() - : null, - backhaulParentBssid: node.backhaulMacAddressMultiAp.trim().isNotEmpty - ? node.backhaulMacAddressMultiAp.trim() - : null, - lastContactTime: node.multiApLastContactTime.trim().isNotEmpty - ? node.multiApLastContactTime.trim() - : null, - )); } - return MeshTopologyInfo(nodes: nodes, clientToNodeMap: clientToNodeMap); + return MeshTopologyInfo( + nodes: nodes, + clientToNodeMap: clientToNodeMap, + clientSignalMap: clientSignalMap, + clientBandSsidMap: clientBandSsidMap, + ); } } diff --git a/lib/page/_shared/utils/wifi_guest_detection.dart b/lib/page/_shared/utils/wifi_guest_detection.dart new file mode 100644 index 000000000..e601799a9 --- /dev/null +++ b/lib/page/_shared/utils/wifi_guest_detection.dart @@ -0,0 +1,15 @@ +import 'package:privacy_gui/generated/wi_fi_ssids.g.dart'; + +/// Canonical guest WiFi detection rule for the whole app. +/// +/// A WiFi SSID is a guest network when its TR-181 `Alias` ends with the +/// `-guest` suffix, which firmware auto-provisions from FW 1.2.1+ +/// (e.g. `wifi-2g-guest`, `wifi-5g-guest`). +/// +/// This is the single source of truth for the Main-vs-Guest distinction. +/// Do not reintroduce instance-index or radio-occupancy heuristics — those +/// disagree with this rule and cause guest networks to be misclassified. +bool isGuestSsidAlias(String? alias) => alias?.endsWith('-guest') ?? false; + +/// Convenience overload for a generated [WiFiSsid] instance. +bool isGuestSsid(WiFiSsid ssid) => isGuestSsidAlias(ssid.alias); diff --git a/lib/page/admin/cards/usp_device_info_card.dart b/lib/page/admin/cards/usp_device_info_card.dart index 6caec4012..8f5176ef7 100644 --- a/lib/page/admin/cards/usp_device_info_card.dart +++ b/lib/page/admin/cards/usp_device_info_card.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import 'package:privacy_gui/core/utils/device_image_helper.dart'; import 'package:privacy_gui/localization/localization_hook.dart'; import 'package:privacy_gui/core/utils/icon_rules.dart'; @@ -25,8 +26,7 @@ class UspDeviceInfoCard extends ConsumerWidget { // Get MAC and hostname from master node final devicesData = ref.watch(devicesDataProvider).valueOrNull; - final masterNode = - devicesData?.nodeModels.where((n) => n.isMaster).firstOrNull; + final masterNode = devicesData?.nodes.where((n) => n.isMaster).firstOrNull; final macAddress = masterNode?.deviceId; final hostName = masterNode?.displayName; @@ -38,7 +38,9 @@ class UspDeviceInfoCard extends ConsumerWidget { return DashboardCardTemplate( title: loc(context).deviceInformation, - detailRoute: RouteNamed.uspAdmin, + footer: masterNode != null && masterNode.deviceId.isNotEmpty + ? _buildNodeDetailFooter(context, masterNode.deviceId) + : null, content: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -127,4 +129,46 @@ class UspDeviceInfoCard extends ConsumerWidget { ), ); } + + Widget _buildNodeDetailFooter(BuildContext context, String deviceId) { + final label = loc(context).viewDetails; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + AppDivider(), + AppGap.md(), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Semantics( + button: true, + label: label, + child: InkWell( + onTap: () => context.pushNamed( + RouteNamed.uspNodeDetail, + queryParameters: {'deviceId': deviceId}, + ), + borderRadius: BorderRadius.circular(4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + AppText.labelMedium( + label, + color: Theme.of(context).colorScheme.primary, + ), + AppGap.xs(), + Icon( + Icons.arrow_forward, + size: 14, + color: Theme.of(context).colorScheme.primary, + ), + ], + ), + ), + ), + ], + ), + ], + ); + } } diff --git a/lib/page/admin/cards/usp_time_settings_card.dart b/lib/page/admin/cards/usp_time_settings_card.dart index da67af24f..4189edf58 100644 --- a/lib/page/admin/cards/usp_time_settings_card.dart +++ b/lib/page/admin/cards/usp_time_settings_card.dart @@ -160,7 +160,7 @@ class _UspTimeSettingsCardState extends ConsumerState localTimeZone: result.localTimeZone, ntpServer1: result.ntpServer1, ), - successMessage: 'Time settings saved', + successMessage: loc(context).timeSettingsSaved, ); } } diff --git a/lib/page/admin/providers/system_info_data_provider.dart b/lib/page/admin/providers/system_info_data_provider.dart index a4ef07d33..f743d3cad 100644 --- a/lib/page/admin/providers/system_info_data_provider.dart +++ b/lib/page/admin/providers/system_info_data_provider.dart @@ -1,19 +1,22 @@ import 'package:equatable/equatable.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:privacy_gui/core/utils/logger.dart'; +import 'package:privacy_gui/framework/diagnostic_loggable.dart'; import 'package:privacy_gui/page/_shared/models/system_info_ui_model.dart'; import 'package:privacy_gui/page/admin/services/usp_system_info_data_service.dart'; import 'package:privacy_gui/page/firmware_update/providers/firmware_banks_data_provider.dart'; // ── Data Model ── -class SystemInfoData extends Equatable { +class SystemInfoData extends Equatable with DiagnosticLoggable { final SystemInfoUIModel model; const SystemInfoData({required this.model}); @override - List get props => [model]; + String get diagnosticName => 'SystemInfoData'; + + @override + Map get namedProps => {'model': model}; } // ── Provider ── @@ -49,10 +52,6 @@ class SystemInfoDataNotifier extends AsyncNotifier { // Service fetches SystemInfo; firmwareBanks passed in externally final model = await svc.fetch(firmwareBanks: banksData?.banks); - logger.d('[USP][SystemInfoData]: Fetched — ' - 'model=${model.modelName}, ' - 'fw=${model.softwareVersion}'); - return SystemInfoData(model: model); } } diff --git a/lib/page/admin/providers/time_data_provider.dart b/lib/page/admin/providers/time_data_provider.dart index da5e347b9..4b3f129ca 100644 --- a/lib/page/admin/providers/time_data_provider.dart +++ b/lib/page/admin/providers/time_data_provider.dart @@ -1,6 +1,6 @@ import 'package:equatable/equatable.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:privacy_gui/core/utils/logger.dart'; +import 'package:privacy_gui/framework/diagnostic_loggable.dart'; import 'package:privacy_gui/page/_shared/models/time_settings_ui_model.dart'; import 'package:privacy_gui/page/admin/services/usp_time_data_service.dart'; @@ -8,14 +8,20 @@ import 'package:privacy_gui/page/admin/services/usp_time_data_service.dart'; // Data Model (Layer 1 — UI model only) // --------------------------------------------------------------------------- -class TimeData extends Equatable { +class TimeData extends Equatable with DiagnosticLoggable { final TimeSettingsUIModel model; final DateTime fetchedAt; TimeData({required this.model}) : fetchedAt = DateTime.now(); @override - List get props => [model, fetchedAt]; + String get diagnosticName => 'TimeData'; + + @override + Map get namedProps => { + 'model': model, + 'fetchedAt': fetchedAt, + }; } // --------------------------------------------------------------------------- @@ -40,9 +46,6 @@ class TimeDataNotifier extends AsyncNotifier { final svc = ref.read(uspTimeDataServiceProvider); final model = await svc.fetch(); - logger.d('[USP][TimeData]: Fetched — ' - 'enable: ${model.enable}, status: ${model.status}'); - return TimeData(model: model); } } diff --git a/lib/page/admin/providers/usp_admin_notifier.dart b/lib/page/admin/providers/usp_admin_notifier.dart index 5087afce3..a1869e56c 100644 --- a/lib/page/admin/providers/usp_admin_notifier.dart +++ b/lib/page/admin/providers/usp_admin_notifier.dart @@ -1,10 +1,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/core/utils/logger.dart'; +import 'package:privacy_gui/core/usp/providers/usp_auth_coordinator.dart'; import 'package:privacy_gui/core/usp/providers/usp_mutation_lock.dart'; import 'package:privacy_gui/page/admin/providers/time_data_provider.dart'; import 'package:privacy_gui/page/admin/providers/usp_admin_state.dart'; import 'package:privacy_gui/page/admin/services/usp_admin_service.dart'; +import 'package:privacy_gui/providers/auth/auth_provider.dart'; final uspAdminProvider = AsyncNotifierProvider.autoDispose( @@ -47,6 +49,19 @@ class UspAdminNotifier extends AutoDisposeAsyncNotifier { logger.e('[USP][Admin]: Password update failed', error: e); rethrow; } + + // Re-authenticate with new password to get a fresh token. + // The old token may be invalidated by the router after password change. + // If relogin fails, logout to force user to re-enter password. + try { + await ref + .read(uspAuthCoordinatorProvider) + .reloginWithNewPassword(newPassword); + } catch (e) { + logger.w('[USP][Admin]: Relogin failed after password change, ' + 'triggering logout: $e'); + await ref.read(authProvider.notifier).logout(); + } } // --------------------------------------------------------------------------- diff --git a/lib/page/admin/views/components/usp_password_card.dart b/lib/page/admin/views/components/usp_password_card.dart index 8afbf4a36..9a98ec2a3 100644 --- a/lib/page/admin/views/components/usp_password_card.dart +++ b/lib/page/admin/views/components/usp_password_card.dart @@ -34,13 +34,7 @@ class UspPasswordCard extends StatelessWidget { size: 20, color: colorScheme.onSurfaceVariant), AppGap.md(), Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AppText.bodyMedium(adminUser.username), - AppText.labelLarge('\u2022' * 12), - ], - ), + child: AppText.labelLarge('\u2022' * 12), ), AppButton.text( label: loc(context).change, diff --git a/lib/page/admin/views/usp_admin_view.dart b/lib/page/admin/views/usp_admin_view.dart index 611ccbe8b..92db7bedc 100644 --- a/lib/page/admin/views/usp_admin_view.dart +++ b/lib/page/admin/views/usp_admin_view.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:privacy_gui/components/localizations/service_error_localizations.dart'; import 'package:privacy_gui/components/shortcuts/dialogs.dart'; +import 'package:privacy_gui/components/views/service_error_view.dart'; +import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/components/shortcuts/snack_bar.dart'; import 'package:privacy_gui/components/ui_kit_page_view.dart'; import 'package:privacy_gui/localization/localization_hook.dart'; @@ -45,34 +47,17 @@ class UspAdminView extends ConsumerWidget { child: AppLoader(), ), ), - error: (error, stack) => _buildError(childContext, ref, error), + error: (error, stack) => ServiceErrorView( + error: error is ServiceError ? error : null, + title: loc(context).failedToLoadSettings, + onRetry: () => ref.invalidate(uspAdminProvider), + ), data: (state) => _buildContent(childContext, ref, state), ); }, ); } - Widget _buildError(BuildContext context, WidgetRef ref, Object error) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - AppIcon.font(Icons.error_outline, - size: 48, color: Theme.of(context).colorScheme.error), - AppGap.xl(), - AppText.titleMedium(loc(context).failedToLoadSettings), - AppGap.md(), - AppText.bodyMedium(localizeServiceError(context, error)), - AppGap.xxl(), - AppButton( - label: loc(context).retry, - onTap: () => ref.invalidate(uspAdminProvider), - ), - ], - ), - ); - } - Widget _buildContent( BuildContext context, WidgetRef ref, UspAdminState state) { return AppResponsiveLayout( diff --git a/lib/page/apps/views/usp_apps_view.dart b/lib/page/apps/views/usp_apps_view.dart index 8e523e323..73c6c909d 100644 --- a/lib/page/apps/views/usp_apps_view.dart +++ b/lib/page/apps/views/usp_apps_view.dart @@ -30,14 +30,18 @@ class UspAppsView extends ConsumerWidget { child: (childContext, constraints) { return asyncState.when( loading: () => const Center(child: AppLoader()), - error: (error, stack) => _buildError(context, ref, error), + error: (error, stack) => _buildError(context, ref), data: (appsState) => _buildContent(context, ref, appsState), ); }, ); } - Widget _buildError(BuildContext context, WidgetRef ref, Object error) { + // Apps are served as static lighttpd JSON (NOT USP/TR-181), so failures are + // plain `Exception`s, not `ServiceError`s — this page keeps its own error + // widget rather than the ServiceError-based `ServiceErrorView`. We show a + // localized message instead of the raw exception text. + Widget _buildError(BuildContext context, WidgetRef ref) { return Center( child: Column( mainAxisSize: MainAxisSize.min, @@ -46,8 +50,6 @@ class UspAppsView extends ConsumerWidget { size: 48, color: Theme.of(context).colorScheme.error), AppGap.xl(), AppText.titleMedium(loc(context).unableToLoadApps), - AppGap.md(), - AppText.bodyMedium(error.toString()), AppGap.xxl(), AppButton( label: loc(context).retry, diff --git a/lib/page/dashboard/mascot/dashboard_dialog_provider.dart b/lib/page/dashboard/mascot/dashboard_dialog_provider.dart index e29ecb5cf..3e927e082 100644 --- a/lib/page/dashboard/mascot/dashboard_dialog_provider.dart +++ b/lib/page/dashboard/mascot/dashboard_dialog_provider.dart @@ -53,6 +53,7 @@ class DashboardDialogProvider extends MascotDialogProvider { return MascotDialogNode( id: 'main', text: '$greeting\nHow can I help you today?', + barrier: MascotDialogBarrier.dismissible, options: [ const MascotDialogOption( id: 'ai_assistant', @@ -218,6 +219,7 @@ class DashboardDialogProvider extends MascotDialogProvider { return const MascotDialogNode( id: 'diagnostics_menu', text: 'What would you like to check?', + barrier: MascotDialogBarrier.dismissible, options: [ MascotDialogOption( id: 'full', @@ -314,6 +316,7 @@ class DashboardDialogProvider extends MascotDialogProvider { text: result.message, type: type, suggestedAnimation: animation, + barrier: MascotDialogBarrier.dismissible, options: const [ MascotDialogOption( id: 'back', @@ -337,6 +340,7 @@ class DashboardDialogProvider extends MascotDialogProvider { return MascotDialogNode( id: 'faq_categories', text: 'What do you need help with?', + barrier: MascotDialogBarrier.dismissible, options: [ ..._faqCategories.asMap().entries.map((entry) => MascotDialogOption( id: 'cat_${entry.key}', @@ -357,6 +361,7 @@ class DashboardDialogProvider extends MascotDialogProvider { return MascotDialogNode( id: 'faq_items_$catIndex', text: getFaqCategoryTitle(category), + barrier: MascotDialogBarrier.dismissible, options: [ ...category.items.asMap().entries.map((entry) => MascotDialogOption( id: 'item_${catIndex}_${entry.key}', diff --git a/lib/page/dashboard/mascot/health/dimensions/devices_dimension.dart b/lib/page/dashboard/mascot/health/dimensions/devices_dimension.dart index c77c9ddee..6591725ec 100644 --- a/lib/page/dashboard/mascot/health/dimensions/devices_dimension.dart +++ b/lib/page/dashboard/mascot/health/dimensions/devices_dimension.dart @@ -64,7 +64,7 @@ class DevicesHealthDimension extends HealthDimension { final online = devices.onlineClientCount; final total = devices.totalClientCount; - final meshNodes = devices.nodeModels.where((n) => !n.isMaster).length; + final meshNodes = devices.nodes.where((n) => !n.isMaster).length; String status; if (total == 0) { diff --git a/lib/page/dashboard/mascot/health_dialog_provider.dart b/lib/page/dashboard/mascot/health_dialog_provider.dart index 2348445e1..840bba979 100644 --- a/lib/page/dashboard/mascot/health_dialog_provider.dart +++ b/lib/page/dashboard/mascot/health_dialog_provider.dart @@ -66,6 +66,7 @@ class HealthDialogProvider extends MascotDialogProvider { return MascotDialogNode.custom( id: 'health_dashboard', text: greeting, + barrier: MascotDialogBarrier.dismissible, contentBuilder: (ctx, textColor) => _buildHealthDashboard(textColor, greeting), ); @@ -167,6 +168,7 @@ class HealthDialogProvider extends MascotDialogProvider { controller.showDialog(MascotDialogNode.custom( id: 'dimension_${dimensionType.name}', text: dimension.displayName, + barrier: MascotDialogBarrier.dismissible, contentBuilder: (ctx, textColor) => DimensionDetailView( dimension: dimension, score: score, @@ -261,6 +263,7 @@ class HealthDialogProvider extends MascotDialogProvider { return const MascotDialogNode( id: 'diagnostics_menu', text: 'What would you like to check?', + barrier: MascotDialogBarrier.dismissible, options: [ MascotDialogOption( id: 'full', @@ -338,6 +341,7 @@ class HealthDialogProvider extends MascotDialogProvider { text: result.message, type: type, suggestedAnimation: animation, + barrier: MascotDialogBarrier.dismissible, options: const [ MascotDialogOption( id: 'back', @@ -371,6 +375,7 @@ class HealthDialogProvider extends MascotDialogProvider { return MascotDialogNode( id: 'faq_categories', text: 'What do you need help with?', + barrier: MascotDialogBarrier.dismissible, options: [ ..._faqCategories.asMap().entries.map((entry) => MascotDialogOption( id: 'cat_${entry.key}', @@ -391,6 +396,7 @@ class HealthDialogProvider extends MascotDialogProvider { return MascotDialogNode( id: 'faq_items_$catIndex', text: getFaqCategoryTitle(category), + barrier: MascotDialogBarrier.dismissible, options: [ ...category.items.asMap().entries.map((entry) => MascotDialogOption( id: 'item_${catIndex}_${entry.key}', diff --git a/lib/page/dashboard/mascot/mascot_message_provider.dart b/lib/page/dashboard/mascot/mascot_message_provider.dart index c60f4bda9..197dd8670 100644 --- a/lib/page/dashboard/mascot/mascot_message_provider.dart +++ b/lib/page/dashboard/mascot/mascot_message_provider.dart @@ -99,8 +99,7 @@ class MascotMessageNotifier { final devicesData = _ref.read(devicesDataProvider).valueOrNull; final onlineDeviceCount = devicesData?.onlineClientCount; final totalDeviceCount = devicesData?.totalClientCount; - final meshNodeCount = - devicesData?.nodeModels.where((n) => !n.isMaster).length; + final meshNodeCount = devicesData?.nodes.where((n) => !n.isMaster).length; // WAN final wanData = _ref.read(wanDataProvider).valueOrNull; diff --git a/lib/page/dashboard/mascot/triggers/mascot_trigger_provider.dart b/lib/page/dashboard/mascot/triggers/mascot_trigger_provider.dart index d336cf608..acdac418e 100644 --- a/lib/page/dashboard/mascot/triggers/mascot_trigger_provider.dart +++ b/lib/page/dashboard/mascot/triggers/mascot_trigger_provider.dart @@ -112,7 +112,7 @@ class MascotTriggerNotifier extends AutoDisposeNotifier { state = MascotTriggerState( previousWanUp: wan?.model.isUp, - previousDeviceCount: devices?.deviceModels.length, + previousDeviceCount: devices?.clientDevices.length, previousFirewallEnabled: firewall?.firewallModel.isIPv4FirewallEnabled, previousDisabledRadios: disabledRadios, ); @@ -203,7 +203,7 @@ class MascotTriggerNotifier extends AutoDisposeNotifier { final devices = ref.read(devicesDataProvider).valueOrNull; if (devices == null) return null; - final currentCount = devices.deviceModels.length; + final currentCount = devices.clientDevices.length; final previousCount = state.previousDeviceCount; // Update state for next comparison @@ -214,10 +214,10 @@ class MascotTriggerNotifier extends AutoDisposeNotifier { if (currentCount <= previousCount) return null; // Find the newest device (last in list by convention) - final newDevice = devices.deviceModels.isNotEmpty - ? (devices.deviceModels.last.hostName.isNotEmpty - ? devices.deviceModels.last.hostName - : devices.deviceModels.last.mac) + final newDevice = devices.clientDevices.isNotEmpty + ? (devices.clientDevices.last.hostName.isNotEmpty + ? devices.clientDevices.last.hostName + : devices.clientDevices.last.mac) : 'Unknown device'; debugPrint('[Mascot][Trigger]: New device joined — $newDevice'); diff --git a/lib/page/dashboard/mascot/widgets/health_status_view.dart b/lib/page/dashboard/mascot/widgets/health_status_view.dart index 084168443..fe9c50730 100644 --- a/lib/page/dashboard/mascot/widgets/health_status_view.dart +++ b/lib/page/dashboard/mascot/widgets/health_status_view.dart @@ -35,7 +35,7 @@ class HealthStatusView extends ConsumerWidget { data: (state) => _buildStatusView(context, ref, state), loading: () => _buildLoading(), error: (_, __) => AppText.bodyMedium( - 'Unable to load health data', + loc(context).unableToLoadHealthData, color: textColor, ), ); diff --git a/lib/page/dashboard/models/usp_dashboard_preset.dart b/lib/page/dashboard/models/usp_dashboard_preset.dart index 18ea3d05f..b14978092 100644 --- a/lib/page/dashboard/models/usp_dashboard_preset.dart +++ b/lib/page/dashboard/models/usp_dashboard_preset.dart @@ -81,6 +81,7 @@ extension UspDashboardPresetX on UspDashboardPreset { 'system_status', 'connected_devices', 'wifi_status', + 'wifi_networks', 'time_settings', 'dhcp_reservations', 'port_forwarding', @@ -176,7 +177,7 @@ List _standardLayout() => [ _item('firewall_overview', x: 6, y: 23, w: 6, h: 4), ]; -/// Professional: all 17 cards — full feature set. +/// Professional: all 18 cards — full feature set. /// /// ``` /// y=0: StatsPanel (12×1) @@ -185,9 +186,10 @@ List _standardLayout() => [ /// y=9: TrafficAnalysis (6×5) | LanInfo (6×3) /// y=14: EthernetPorts (6×3) | ConnectedDevices (6×4) /// y=18: Topology (6×5) | DeviceAnalytics (6×5) -/// y=23: WiFiStatus (6×6) | WiFiPerformance (6×5) -/// y=29: FirewallOverview (6×4) | TimeSettings (6×3) -/// y=33: DhcpReservations (6×4) | PortForwarding (6×4) +/// y=23: WiFiStatus (6×4) | WiFiPerformance (6×5) +/// y=28: WiFiNetworks (6×4) | FirewallOverview (6×4) +/// y=32: TimeSettings (6×3) | DhcpReservations (6×4) +/// y=36: PortForwarding (6×4) /// ``` List _professionalLayout() => [ _item('stats_panel', x: 0, y: 0, w: 12, h: 1), @@ -203,10 +205,11 @@ List _professionalLayout() => [ _item('device_analytics', x: 6, y: 18, w: 6, h: 5), _item('wifi_status', x: 0, y: 23, w: 6, h: 4), _item('wifi_performance', x: 6, y: 23, w: 6, h: 5), - _item('firewall_overview', x: 0, y: 29, w: 6, h: 4), - _item('time_settings', x: 6, y: 29, w: 6, h: 3), - _item('dhcp_reservations', x: 0, y: 33, w: 6, h: 4), - _item('port_forwarding', x: 6, y: 33, w: 6, h: 4), + _item('wifi_networks', x: 0, y: 28, w: 6, h: 4), + _item('firewall_overview', x: 6, y: 28, w: 6, h: 4), + _item('time_settings', x: 0, y: 32, w: 6, h: 3), + _item('dhcp_reservations', x: 6, y: 32, w: 6, h: 4), + _item('port_forwarding', x: 0, y: 36, w: 6, h: 4), ]; /// Monitoring: 8 cards — performance & analytics prominent. diff --git a/lib/page/dashboard/orchestrator/dashboard_orchestrator.dart b/lib/page/dashboard/orchestrator/dashboard_orchestrator.dart index e7b28eea4..9ae3ebb23 100644 --- a/lib/page/dashboard/orchestrator/dashboard_orchestrator.dart +++ b/lib/page/dashboard/orchestrator/dashboard_orchestrator.dart @@ -129,12 +129,16 @@ class DashboardOrchestrator extends AsyncNotifier { // Remote Assistance mode: client is pre-authenticated via authToken, // skip local USP auth check. - final loginType = ref.read(authProvider).value?.loginType; - final isRemoteAssistance = loginType == LoginType.remote; + final isRemoteAssistance = + ref.read(authProvider).value?.isRemoteAssistance ?? false; - // On page reload WASM state is lost — attempt session restore (local only) + // On page reload WASM state is lost — attempt session restore (local only). + // Use isRecovering: true because we handle auth failure via NotAuthenticatedError + // below — don't let restoreSession trigger onForceLogout (double navigation). if (!isRemoteAssistance && !usp.isAuthenticated) { - await ref.read(uspAuthCoordinatorProvider).restoreSession(); + await ref + .read(uspAuthCoordinatorProvider) + .restoreSession(isRecovering: true); if (!usp.isAuthenticated) { throw const NotAuthenticatedError(); } @@ -175,6 +179,7 @@ class DashboardOrchestrator extends AsyncNotifier { memoryPercent: model.memoryPercent, totalMemoryKb: model.totalMemory, freeMemoryKb: model.freeMemory, + uptimeSeconds: model.uptime, ), ); }).catchError((e) { diff --git a/lib/page/dashboard/providers/dashboard_edit_mode_provider.dart b/lib/page/dashboard/providers/dashboard_edit_mode_provider.dart new file mode 100644 index 000000000..99e9570d8 --- /dev/null +++ b/lib/page/dashboard/providers/dashboard_edit_mode_provider.dart @@ -0,0 +1,111 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacy_gui/page/dashboard/models/usp_layout_preferences.dart'; +import 'package:privacy_gui/page/dashboard/providers/usp_layout_controller.dart'; +import 'package:privacy_gui/page/dashboard/providers/usp_layout_preferences_provider.dart'; + +/// Manages dashboard edit mode state and layout snapshots for revert on cancel. +/// +/// This provider centralizes edit mode state so it can be accessed from route +/// guards (onExit) to handle navigation away during edit mode. +final dashboardEditModeProvider = + NotifierProvider( + DashboardEditModeNotifier.new, +); + +class DashboardEditState { + final bool isEditing; + final List>? layoutSnapshot; + final UspLayoutPreferences? prefsSnapshot; + + const DashboardEditState({ + this.isEditing = false, + this.layoutSnapshot, + this.prefsSnapshot, + }); + + DashboardEditState copyWith({ + bool? isEditing, + List>? layoutSnapshot, + UspLayoutPreferences? prefsSnapshot, + bool clearSnapshots = false, + }) { + return DashboardEditState( + isEditing: isEditing ?? this.isEditing, + layoutSnapshot: + clearSnapshots ? null : (layoutSnapshot ?? this.layoutSnapshot), + prefsSnapshot: + clearSnapshots ? null : (prefsSnapshot ?? this.prefsSnapshot), + ); + } +} + +class DashboardEditModeNotifier extends Notifier { + @override + DashboardEditState build() => const DashboardEditState(); + + /// Enter edit mode and capture snapshots for potential revert. + Future enterEditMode() async { + // Re-entrant guard: a double-tap or gesture race must not re-capture the + // already-modified grid as the "original" snapshot. + if (state.isEditing) return; + + // Claim the edit slot BEFORE the await so a route guard (onExit) firing in + // the async gap always observes isEditing=true and reverts correctly. + state = const DashboardEditState(isEditing: true); + + await ref.read(uspLayoutPreferencesProvider.notifier).initialized; + + // If we were cancelled during the await (e.g. navigation away), bail out + // instead of resuming and stranding the controller in edit mode. + if (!state.isEditing) return; + + final controller = ref.read(uspSliverDashboardControllerProvider); + final layoutSnapshot = controller.exportLayout(); + final prefsSnapshot = ref.read(uspLayoutPreferencesProvider); + + state = DashboardEditState( + isEditing: true, + layoutSnapshot: layoutSnapshot, + prefsSnapshot: prefsSnapshot, + ); + + controller.setEditMode(true); + } + + /// Exit edit mode, keeping the current layout (changes are already persisted + /// on each drag/resize, so committing is just clearing the edit flag). + Future commitEditMode() => _exitEditMode(revert: false); + + /// Exit edit mode and revert the layout/prefs to the pre-edit snapshots + /// captured in [enterEditMode]. + Future cancelEditMode() => _exitEditMode(revert: true); + + /// Shared exit path for [commitEditMode] / [cancelEditMode]. + /// + /// The edit flag and snapshots are always cleared in the `finally` block so + /// that a failure in [DashboardController.saveLayout] / + /// [UspLayoutPreferencesNotifier.restoreSnapshot] can never leave the + /// dashboard stuck in edit mode with stale state. + Future _exitEditMode({required bool revert}) async { + final controller = ref.read(uspSliverDashboardControllerProvider); + + try { + if (revert) { + if (state.layoutSnapshot != null) { + controller.importLayout(state.layoutSnapshot!); + await ref + .read(uspSliverDashboardControllerProvider.notifier) + .saveLayout(); + } + if (state.prefsSnapshot != null) { + await ref + .read(uspLayoutPreferencesProvider.notifier) + .restoreSnapshot(state.prefsSnapshot!); + } + } + } finally { + controller.setEditMode(false); + state = const DashboardEditState(); + } + } +} diff --git a/lib/page/dashboard/providers/pdf_report_data_provider.dart b/lib/page/dashboard/providers/pdf_report_data_provider.dart index 08bec4565..20f9a6317 100644 --- a/lib/page/dashboard/providers/pdf_report_data_provider.dart +++ b/lib/page/dashboard/providers/pdf_report_data_provider.dart @@ -62,7 +62,7 @@ final pdfReportDataProvider = Provider((ref) { wanStatus: ref.read(wanDataProvider).valueOrNull?.model, systemInfo: ref.read(systemInfoDataProvider).valueOrNull?.model, radioModels: ref.read(wifiDataProvider).valueOrNull?.radioModels, - deviceModels: ref.read(devicesDataProvider).valueOrNull?.deviceModels, - nodeModels: ref.read(devicesDataProvider).valueOrNull?.nodeModels, + clientDevices: ref.read(devicesDataProvider).valueOrNull?.clientDevices, + nodes: ref.read(devicesDataProvider).valueOrNull?.nodes, ); }); diff --git a/lib/page/dashboard/views/components/usp_device_analytics_card.dart b/lib/page/dashboard/views/components/usp_device_analytics_card.dart index 63329906a..413475f25 100644 --- a/lib/page/dashboard/views/components/usp_device_analytics_card.dart +++ b/lib/page/dashboard/views/components/usp_device_analytics_card.dart @@ -5,14 +5,15 @@ import 'package:privacy_gui/page/_shared/models/device_analytics_state.dart'; import 'package:privacy_gui/page/_shared/providers/card_tab_state_provider.dart'; import 'package:privacy_gui/page/_shared/providers/usp_device_analytics_notifier.dart'; import 'package:privacy_gui/page/_shared/components/dashboard_card_template.dart'; +import 'package:privacy_gui/page/_shared/components/layout_blocks.dart'; import 'package:ui_kit_library/ui_kit.dart'; /// Device Connection Analytics card — 4 chart views via tab selector. /// -/// - Distribution (Donut): WiFi vs Wired with band breakdown -/// - Trend (Stacked Column): 24h hourly device count +/// - Overview (Donut): Online/Offline with WiFi/Wired breakdown +/// - Signal (Bar): WiFi signal quality distribution (Excellent/Good/Fair/Poor) +/// - Trend (Line): 24h total device count /// - Activity (Heatmap): 24h per-device activity matrix -/// - Signal (Radar): WiFi signal quality per band class UspDeviceAnalyticsCard extends ConsumerStatefulWidget { const UspDeviceAnalyticsCard({super.key}); @@ -41,15 +42,15 @@ class _UspDeviceAnalyticsCardState content: _buildChartView(context, analyticsState, 0), ), CardTab( - label: loc(context).trend, + label: loc(context).signal, content: _buildChartView(context, analyticsState, 1), ), CardTab( - label: loc(context).activity, + label: loc(context).trend, content: _buildChartView(context, analyticsState, 2), ), CardTab( - label: loc(context).signal, + label: loc(context).activity, content: _buildChartView(context, analyticsState, 3), ), ], @@ -65,21 +66,21 @@ class _UspDeviceAnalyticsCardState return switch (selectedTab) { 0 => current != null - ? _DistributionView(distribution: current) + ? _OverviewView(distribution: current) : _buildEmptyState(context, loc(context).waitingForDeviceData), - 1 => state.hourlyHistory.isNotEmpty + 1 => current != null && current.signalLevelDistribution.isNotEmpty + ? _SignalView(distribution: current) + : _buildEmptyState(context, loc(context).noWifiSignalData), + 2 => state.hourlyHistory.isNotEmpty ? _TrendView(history: state.hourlyHistory) : _buildEmptyState(context, loc(context).collectingHourlyData), - 2 => state.hourlyHistory.isNotEmpty + 3 => state.hourlyHistory.isNotEmpty ? _ActivityView( history: state.hourlyHistory, allKnownMacs: state.allKnownMacs, macDisplayNames: state.macDisplayNames, ) : _buildEmptyState(context, loc(context).collectingActivityData), - 3 => current != null && current.bandSignalQuality.isNotEmpty - ? _SignalView(distribution: current) - : _buildEmptyState(context, loc(context).noWifiSignalData), _ => const SizedBox.shrink(), }; } @@ -95,16 +96,19 @@ class _UspDeviceAnalyticsCardState } // ============================================================================= -// Tab 1: Distribution (Donut + Band bars) +// Tab 1: Overview (Online/Offline donut + WiFi/Wired breakdown) // ============================================================================= -class _DistributionView extends StatelessWidget { +class _OverviewView extends StatelessWidget { final DeviceDistribution distribution; - const _DistributionView({required this.distribution}); + const _OverviewView({required this.distribution}); @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + final online = distribution.onlineCount; + final offline = distribution.offlineCount; + final total = distribution.totalCount; return Column( children: [ @@ -113,20 +117,23 @@ class _DistributionView extends StatelessWidget { child: AppPieChart( sections: [ AppPieSection( - value: distribution.wifiCount.toDouble(), - label: loc(context).wifi, - color: colorScheme.primary), - AppPieSection( - value: distribution.wiredCount.toDouble(), - label: loc(context).wired, - color: colorScheme.secondary), + value: online.toDouble(), + label: loc(context).online, + color: colorScheme.primary, + ), + if (offline > 0) + AppPieSection( + value: offline.toDouble(), + label: loc(context).offline, + color: colorScheme.outlineVariant, + ), ], donut: true, centerWidget: Column( mainAxisSize: MainAxisSize.min, children: [ - AppText.titleMedium('${distribution.onlineCount}'), - AppText.labelSmall(loc(context).online, + AppText.titleMedium('$total'), + AppText.labelSmall(loc(context).devices, color: colorScheme.onSurfaceVariant), ], ), @@ -134,27 +141,95 @@ class _DistributionView extends StatelessWidget { ), ), ), - AppGap.sm(), - if (distribution.bandDistribution.isNotEmpty) - _BandDistributionBars( - bandDistribution: distribution.bandDistribution, - ), + AppGap.md(), + // Connection type + status breakdown + Row( + children: [ + Expanded( + child: LayoutBlock( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, vertical: AppSpacing.sm), + child: Row( + children: [ + Icon(Icons.wifi, color: colorScheme.primary, size: 20), + AppGap.sm(), + Expanded( + child: AppText.bodyMedium(loc(context).wifi), + ), + AppText.titleSmall('${distribution.wifiCount}'), + ], + ), + ), + ), + AppGap.sm(), + Expanded( + child: LayoutBlock( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, vertical: AppSpacing.sm), + child: Row( + children: [ + Icon(Icons.settings_ethernet, + color: colorScheme.secondary, size: 20), + AppGap.sm(), + Expanded( + child: AppText.bodyMedium(loc(context).wired), + ), + AppText.titleSmall('${distribution.wiredCount}'), + ], + ), + ), + ), + ], + ), AppGap.sm(), Row( - mainAxisAlignment: MainAxisAlignment.center, children: [ - _LegendDot(color: colorScheme.primary), - AppGap.xs(), - AppText.labelSmall(loc(context).wifiCount(distribution.wifiCount)), - AppGap.lg(), - _LegendDot(color: colorScheme.secondary), - AppGap.xs(), - AppText.labelSmall( - loc(context).wiredCount(distribution.wiredCount)), - AppGap.lg(), - AppText.labelSmall( - loc(context).nOffline(distribution.offlineCount), - color: colorScheme.onSurfaceVariant, + Expanded( + child: LayoutBlock( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, vertical: AppSpacing.sm), + child: Row( + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: colorScheme.primary, + shape: BoxShape.circle, + ), + ), + AppGap.sm(), + Expanded( + child: AppText.bodyMedium(loc(context).online), + ), + AppText.titleSmall('$online'), + ], + ), + ), + ), + AppGap.sm(), + Expanded( + child: LayoutBlock( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, vertical: AppSpacing.sm), + child: Row( + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: colorScheme.outlineVariant, + shape: BoxShape.circle, + ), + ), + AppGap.sm(), + Expanded( + child: AppText.bodyMedium(loc(context).offline), + ), + AppText.titleSmall('$offline'), + ], + ), + ), ), ], ), @@ -163,82 +238,82 @@ class _DistributionView extends StatelessWidget { } } -class _BandDistributionBars extends StatelessWidget { - final Map bandDistribution; +// ============================================================================= +// Tab 2: Signal (Signal quality distribution) +// ============================================================================= - const _BandDistributionBars({required this.bandDistribution}); +class _SignalView extends StatelessWidget { + final DeviceDistribution distribution; + const _SignalView({required this.distribution}); @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - final maxCount = bandDistribution.values.fold(0, (a, b) => a > b ? a : b); - final seriesColors = [ - colorScheme.primary, - colorScheme.secondary, - colorScheme.tertiary, + final signalDist = distribution.signalLevelDistribution; + + // Signal levels: 3=Excellent, 2=Good, 1=Fair, 0=Poor + final levels = [ + (3, loc(context).excellent, colorScheme.primary), + (2, loc(context).good, Colors.lightGreen), + (1, loc(context).fair, Colors.orange), + (0, loc(context).poor, colorScheme.error), ]; + final data = levels.map((l) => (signalDist[l.$1] ?? 0).toDouble()).toList(); + final labels = levels.map((l) => l.$2).toList(); + final colors = levels.map((l) => l.$3).toList(); + final total = data.fold(0.0, (a, b) => a + b); + return Column( children: [ - for (var i = 0; i < bandDistribution.entries.length; i++) - Padding( - padding: EdgeInsets.only(bottom: 2), - child: Row( - children: [ - SizedBox( - width: 56, - child: AppText.labelSmall( - bandDistribution.entries.elementAt(i).key, - textAlign: TextAlign.end, - ), - ), - AppGap.sm(), - Expanded( - child: _HorizontalBar( - value: bandDistribution.entries.elementAt(i).value, - maxValue: maxCount, - color: seriesColors[i % seriesColors.length], - ), - ), - AppGap.sm(), - SizedBox( - width: 20, - child: AppText.labelSmall( - '${bandDistribution.entries.elementAt(i).value}', + Expanded( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: AppBarChart( + series: [ + for (var i = 0; i < levels.length; i++) + AppChartSeries( + label: labels[i], + data: [data[i]], + color: colors[i], ), - ), ], + xLabels: [''], + yAxis: AppChartAxis( + min: 0, + max: total > 0 ? total : 1, + interval: (total / 4).ceilToDouble().clamp(1, double.infinity), + ), + showTooltip: false, ), ), + ), + AppGap.sm(), + // Legend + Wrap( + alignment: WrapAlignment.center, + spacing: AppSpacing.md, + runSpacing: AppSpacing.xs, + children: [ + for (var i = 0; i < levels.length; i++) + if (data[i] > 0) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + _LegendDot(color: colors[i]), + AppGap.xs(), + AppText.labelSmall('${labels[i]}: ${data[i].toInt()}'), + ], + ), + ], + ), ], ); } } -class _HorizontalBar extends StatelessWidget { - final int value; - final int maxValue; - final Color color; - - const _HorizontalBar({ - required this.value, - required this.maxValue, - required this.color, - }); - - @override - Widget build(BuildContext context) { - final fraction = maxValue > 0 ? value / maxValue : 0.0; - return AppLoader( - variant: LoaderVariant.linear, - value: fraction, - color: color, - ); - } -} - // ============================================================================= -// Tab 2: Trend (Stacked Column Chart) +// Tab 3: Trend (Total device count line chart) // ============================================================================= class _TrendView extends StatelessWidget { @@ -255,51 +330,39 @@ class _TrendView extends StatelessWidget { final slots = List.generate(24, (i) { final hour = currentHour.subtract(Duration(hours: 23 - i)); final match = history.where((h) => h.hour == hour).firstOrNull; - return ( - hour: hour, - wifi: match?.wifiCount ?? 0, - wired: match?.wiredCount ?? 0, - ); + final total = (match?.wifiCount ?? 0) + (match?.wiredCount ?? 0); + return (hour: hour, total: total); }); - final wifiData = slots.map((s) => s.wifi.toDouble()).toList(); - final wiredData = slots.map((s) => s.wired.toDouble()).toList(); - final xLabels = slots - .map( - (s) => s.hour.hour % 3 == 0 ? '${s.hour.hour}'.padLeft(2, '0') : '') - .toList(); + final totalData = slots.map((s) => s.total.toDouble()).toList(); + + // Calculate Y-axis bounds to avoid duplicate labels with small counts + final maxCount = slots.map((s) => s.total).reduce((a, b) => a > b ? a : b); + final yMax = maxCount < 2 ? 2.0 : (maxCount + 1).toDouble(); + final yInterval = yMax <= 4 ? 1.0 : (yMax / 4).ceilToDouble(); return Column( children: [ Expanded( - child: AppBarChart( - series: [ - AppChartSeries( - label: loc(context).wifi, - data: wifiData, - color: colorScheme.primary), - AppChartSeries( - label: loc(context).wired, - data: wiredData, - color: colorScheme.secondary), - ], - stacked: true, - xLabels: xLabels, - showTooltip: false, + child: Padding( + padding: EdgeInsets.only(top: 8), + child: AppLineChart( + series: [ + AppChartSeries( + label: loc(context).devices, + data: totalData, + color: colorScheme.primary, + ), + ], + yAxis: AppChartAxis(min: 0, max: yMax, interval: yInterval), + showTooltip: false, + ), ), ), AppGap.sm(), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _LegendDot(color: colorScheme.primary), - AppGap.xs(), - AppText.labelSmall(loc(context).wifi), - AppGap.lg(), - _LegendDot(color: colorScheme.secondary), - AppGap.xs(), - AppText.labelSmall(loc(context).wired), - ], + AppText.labelSmall( + '24h', + color: colorScheme.onSurfaceVariant, ), ], ); @@ -378,85 +441,6 @@ class _ActivityView extends StatelessWidget { } } -// ============================================================================= -// Tab 4: Signal (Radar / Bar fallback) -// ============================================================================= - -class _SignalView extends StatelessWidget { - final DeviceDistribution distribution; - const _SignalView({required this.distribution}); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final bands = distribution.bandSignalQuality; - - // Radar chart needs >= 3 axes; fallback to bar comparison for fewer - final useRadar = bands.length >= 3; - - return Column( - children: [ - Expanded( - child: Padding( - padding: EdgeInsets.only(top: 16), - child: useRadar - ? AppRadarChart( - series: [ - AppRadarSeries( - label: loc(context).signalQuality, - data: bands.values.map((v) => v * 100).toList(), - color: colorScheme.primary, - filled: true, - ), - ], - axisLabels: bands.keys.toList(), - tickCount: 4, - ) - : AppBarChart( - series: [ - AppChartSeries( - label: loc(context).signal, - data: bands.values.map((v) => v * 100).toList(), - color: colorScheme.primary, - ), - ], - xLabels: bands.keys.toList(), - yAxis: AppChartAxis(min: 0, max: 100, interval: 25), - yLabelFormatter: (v) => '${v.toInt()}%', - showValueLabels: true, - valueLabelFormatter: (v) => '${v.toInt()}%', - showTooltip: false, - ), - ), - ), - AppGap.sm(), - if (distribution.signalLevelDistribution.isNotEmpty) - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - for (final entry in [ - (3, loc(context).excellent, colorScheme.primary), - (2, loc(context).good, Colors.lightGreen), - (1, loc(context).fair, Colors.orange), - (0, loc(context).poor, colorScheme.error), - ]) ...[ - if (distribution.signalLevelDistribution - .containsKey(entry.$1)) ...[ - _LegendDot(color: entry.$3), - AppGap.xs(), - AppText.labelSmall( - '${entry.$2}: ${distribution.signalLevelDistribution[entry.$1]}', - ), - AppGap.md(), - ], - ], - ], - ), - ], - ); - } -} - // ============================================================================= // Shared widgets // ============================================================================= diff --git a/lib/page/dashboard/views/components/usp_stats_panel.dart b/lib/page/dashboard/views/components/usp_stats_panel.dart index 6d998f51c..5d41772e0 100644 --- a/lib/page/dashboard/views/components/usp_stats_panel.dart +++ b/lib/page/dashboard/views/components/usp_stats_panel.dart @@ -20,9 +20,9 @@ class UspStatsPanel extends ConsumerWidget { final ethernetData = ref.watch(ethernetDataProvider).valueOrNull; if (devicesData == null) return const CardSkeleton.stats(); - final devices = devicesData.deviceModels; + final devices = devicesData.clientDevices; final onlineCount = devices.where((d) => d.isActive).length; - final nodeCount = devicesData.nodeModels.length; + final nodeCount = devicesData.nodes.length; final wifiData = ref.watch(wifiDataProvider).valueOrNull; final radioCount = wifiData?.radioModels.length ?? 0; final enabledRadios = diff --git a/lib/page/dashboard/views/components/usp_system_status_card.dart b/lib/page/dashboard/views/components/usp_system_status_card.dart index 89c4527bc..ae3feb70d 100644 --- a/lib/page/dashboard/views/components/usp_system_status_card.dart +++ b/lib/page/dashboard/views/components/usp_system_status_card.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import 'package:privacy_gui/localization/localization_hook.dart'; import 'package:privacy_gui/page/_shared/utils/usp_formatters.dart'; import 'package:privacy_gui/page/_shared/models/system_info_ui_model.dart'; @@ -12,6 +13,7 @@ import 'package:privacy_gui/page/_shared/providers/usp_traffic_analysis_notifier import 'package:privacy_gui/page/_shared/components/card_skeleton.dart'; import 'package:privacy_gui/page/_shared/components/dashboard_card_template.dart'; import 'package:privacy_gui/page/_shared/components/usp_info_row.dart'; +import 'package:privacy_gui/route/constants.dart'; import 'package:ui_kit_library/ui_kit.dart'; /// System Performance Dashboard — 4-tab card (F-021). @@ -31,8 +33,7 @@ class UspSystemStatusCard extends ConsumerStatefulWidget { class _UspSystemStatusCardState extends ConsumerState { static const _cardId = 'system_status'; - List<(Duration?, String)> _intervalOptions(BuildContext context) => [ - (null, loc(context).off), + List<(Duration, String)> _intervalOptions(BuildContext context) => [ (Duration(seconds: 10), '10s'), (Duration(seconds: 30), '30s'), (Duration(minutes: 1), '60s'), @@ -47,6 +48,7 @@ class _UspSystemStatusCardState extends ConsumerState { return DashboardCardTemplate.tabbed( title: loc(context).systemStatus, + footer: _buildStatisticsFooter(context, 2), titleBadge: monitorState.isFetching ? SizedBox( width: 14, @@ -105,6 +107,48 @@ class _UspSystemStatusCardState extends ConsumerState { ), ); } + + Widget _buildStatisticsFooter(BuildContext context, int tabIndex) { + final label = loc(context).viewDetails; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + AppDivider(), + AppGap.md(), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Semantics( + button: true, + label: label, + child: InkWell( + onTap: () => context.pushNamed( + RouteNamed.uspStatistics, + queryParameters: {'tab': tabIndex.toString()}, + ), + borderRadius: BorderRadius.circular(4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + AppText.labelMedium( + label, + color: Theme.of(context).colorScheme.primary, + ), + AppGap.xs(), + Icon( + Icons.arrow_forward, + size: 14, + color: Theme.of(context).colorScheme.primary, + ), + ], + ), + ), + ), + ], + ), + ], + ); + } } // ============================================================================= @@ -118,11 +162,11 @@ class _MonitorView extends StatelessWidget { const _MonitorView({required this.info, required this.monitorState}); String _formatIntervalLabel(BuildContext context, Duration? interval) { - if (interval == null) return loc(context).off; + if (interval == null) return '—'; if (interval.inSeconds == 10) return '10s'; if (interval.inSeconds == 30) return '30s'; if (interval.inSeconds == 60) return '60s'; - return loc(context).off; + return '${interval.inSeconds}s'; } @override @@ -143,7 +187,10 @@ class _MonitorView extends StatelessWidget { return Column( children: [ - UspInfoRow(label: loc(context).uptime, value: info.formattedUptime), + UspInfoRow( + label: loc(context).uptime, + value: latest?.formattedUptime ?? info.formattedUptime, + ), AppGap.md(), Expanded( child: Row( diff --git a/lib/page/dashboard/views/components/usp_traffic_analysis_card.dart b/lib/page/dashboard/views/components/usp_traffic_analysis_card.dart index 70d5f2ff4..a2f259a6c 100644 --- a/lib/page/dashboard/views/components/usp_traffic_analysis_card.dart +++ b/lib/page/dashboard/views/components/usp_traffic_analysis_card.dart @@ -3,12 +3,14 @@ import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import 'package:privacy_gui/localization/localization_hook.dart'; import 'package:privacy_gui/page/_shared/utils/usp_formatters.dart'; import 'package:privacy_gui/page/_shared/models/traffic_analysis_state.dart'; import 'package:privacy_gui/page/_shared/providers/card_tab_state_provider.dart'; import 'package:privacy_gui/page/_shared/providers/usp_traffic_analysis_notifier.dart'; import 'package:privacy_gui/page/_shared/components/dashboard_card_template.dart'; +import 'package:privacy_gui/route/constants.dart'; import 'package:ui_kit_library/ui_kit.dart'; /// Unified traffic monitor card — real-time WAN speed + multi-interface @@ -30,8 +32,7 @@ class _UspTrafficAnalysisCardState extends ConsumerState { static const _cardId = 'traffic_analysis'; - List<(Duration?, String)> _intervalOptions(BuildContext context) => [ - (null, loc(context).off), + List<(Duration, String)> _intervalOptions(BuildContext context) => [ (Duration(seconds: 2), '2s'), (Duration(seconds: 5), '5s'), (Duration(seconds: 10), '10s'), @@ -44,6 +45,7 @@ class _UspTrafficAnalysisCardState return DashboardCardTemplate.tabbed( title: loc(context).trafficMonitor, + footer: _buildStatisticsFooter(context, 0), titleBadge: analysisState.isFetching ? SizedBox( width: 14, @@ -91,11 +93,12 @@ class _UspTrafficAnalysisCardState } String _intervalLabel(BuildContext context, Duration? interval) { + if (interval == null) return '—'; return _intervalOptions(context) .where((e) => e.$1 == interval) .map((e) => e.$2) .firstOrNull ?? - loc(context).off; + '${interval.inSeconds}s'; } Widget _buildChartView( @@ -134,6 +137,48 @@ class _UspTrafficAnalysisCardState ), ); } + + Widget _buildStatisticsFooter(BuildContext context, int tabIndex) { + final label = loc(context).viewDetails; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + AppDivider(), + AppGap.md(), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Semantics( + button: true, + label: label, + child: InkWell( + onTap: () => context.pushNamed( + RouteNamed.uspStatistics, + queryParameters: {'tab': tabIndex.toString()}, + ), + borderRadius: BorderRadius.circular(4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + AppText.labelMedium( + label, + color: Theme.of(context).colorScheme.primary, + ), + AppGap.xs(), + Icon( + Icons.arrow_forward, + size: 14, + color: Theme.of(context).colorScheme.primary, + ), + ], + ), + ), + ), + ], + ), + ], + ); + } } // ============================================================================= diff --git a/lib/page/dashboard/views/dialogs/dhcp_reservation_dialog.dart b/lib/page/dashboard/views/dialogs/dhcp_reservation_dialog.dart deleted file mode 100644 index 30993a0df..000000000 --- a/lib/page/dashboard/views/dialogs/dhcp_reservation_dialog.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:privacy_gui/localization/localization_hook.dart'; -import 'package:ui_kit_library/ui_kit.dart'; - -/// Dialog for adding a new DHCP reservation. -/// -/// Returns a `({String mac, String ip, bool enable})` record on Add, or null on Cancel. -class DhcpReservationDialog extends StatefulWidget { - const DhcpReservationDialog({super.key}); - - @override - State createState() => _DhcpReservationDialogState(); -} - -class _DhcpReservationDialogState extends State { - final _macController = TextEditingController(); - final _ipController = TextEditingController(); - bool _enabled = true; - - @override - void dispose() { - _macController.dispose(); - _ipController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: Text(loc(context).addDhcpReservation), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - AppTextField( - controller: _macController, - hintText: 'MAC Address (e.g. AA:BB:CC:DD:EE:FF)', - ), - AppGap.lg(), - AppTextField( - controller: _ipController, - hintText: 'IP Address (e.g. 192.168.1.100)', - ), - AppGap.lg(), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - AppText.bodyMedium(loc(context).enabled), - AppSwitch( - value: _enabled, - onChanged: (value) => setState(() => _enabled = value), - ), - ], - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: Text(loc(context).cancel), - ), - FilledButton( - onPressed: () { - final mac = _macController.text.trim(); - final ip = _ipController.text.trim(); - if (mac.isEmpty || ip.isEmpty) return; - Navigator.of(context).pop((mac: mac, ip: ip, enable: _enabled)); - }, - child: Text(loc(context).add), - ), - ], - ); - } -} diff --git a/lib/page/dashboard/views/dialogs/wifi_channel_dialog.dart b/lib/page/dashboard/views/dialogs/wifi_channel_dialog.dart index c6c04033c..bacf8e6b6 100644 --- a/lib/page/dashboard/views/dialogs/wifi_channel_dialog.dart +++ b/lib/page/dashboard/views/dialogs/wifi_channel_dialog.dart @@ -1,11 +1,17 @@ import 'package:flutter/material.dart'; +import 'package:privacy_gui/core/utils/wifi_channel.dart'; import 'package:privacy_gui/localization/localization_hook.dart'; import 'package:privacy_gui/page/_shared/models/wifi_radio_ui_model.dart'; import 'package:ui_kit_library/ui_kit.dart'; /// Dialog for editing WiFi radio channel settings. /// -/// Returns a `({int channel, bool autoChannel})` record on Apply, or null on Cancel. +/// The channel is chosen from an [AppDropdown] whose options are built +/// synchronously from [WifiRadioUIModel.possibleChannels] (already fetched at +/// dashboard load time — no per-dialog fetch, loading, or error state). +/// +/// Returns a `({int channel, bool autoChannel})` record on Apply, or `null` +/// on Cancel or when the selection is unchanged (no-op). class WifiChannelDialog extends StatefulWidget { final WifiRadioUIModel radio; @@ -16,29 +22,68 @@ class WifiChannelDialog extends StatefulWidget { } class _WifiChannelDialogState extends State { - late bool _autoChannel; - late TextEditingController _channelController; + /// Sentinel dropdown value representing the "Auto (recommended)" option. + static const int _autoValue = -1; + + /// Currently-selected dropdown value; [_autoValue] means Auto. + late int _selected; + + /// The value [_selected] held when the dialog opened. Apply is a no-op unless + /// the user moves away from this — compared against the initial *dropdown* + /// selection, not the radio's stored channel, so a stored channel that was + /// filtered out of the list (e.g. a DFS channel while DFS is off, which the + /// firmware leaves in place) is not mistaken for a user change. + late final int _initialSelected; + + /// Manual channels available for this radio's band, sorted ascending. + late final List _channels; + + bool get _autoChannel => _selected == _autoValue; + + bool get _hasManualChannels => _channels.isNotEmpty; @override void initState() { super.initState(); - _autoChannel = widget.radio.autoChannelEnable; - _channelController = - TextEditingController(text: widget.radio.channel.toString()); + // Hide 5 GHz DFS channels when DFS (IEEE 802.11h) is disabled — the + // firmware leaves them in PossibleChannels regardless of DFS state. + _channels = filterDfsChannels( + widget.radio.possibleChannels, + band: widget.radio.band, + dfsEnabled: widget.radio.isDfsEnabled, + ); + // AC5: a stored channel that is no longer selectable defaults to Auto + // (no ghost value is ever shown). + final storedChannelSelectable = !widget.radio.autoChannelEnable && + _channels.contains(widget.radio.channel); + _selected = storedChannelSelectable ? widget.radio.channel : _autoValue; + _initialSelected = _selected; } - @override - void dispose() { - _channelController.dispose(); - super.dispose(); + bool _isDfs(int channel) => isDfsChannel(channel, band: widget.radio.band); + + String _labelFor(int value) { + if (value == _autoValue) return loc(context).channelAutoRecommended; + final suffix = _isDfs(value) ? ' ${loc(context).channelDfsSuffix}' : ''; + return '$value$suffix'; } @override Widget build(BuildContext context) { + // AC2/AC6: options are Auto + the band's manual channels. When there are + // no manual channels the dropdown collapses to a single locked Auto entry. + final items = [_autoValue, ..._channels]; + + // AC3: Auto switch ON => dropdown disabled (shows Auto). + // Auto switch OFF => dropdown enabled. + // AC6: no manual channels => dropdown is always disabled (locked to Auto). + final dropdownEnabled = _hasManualChannels && !_autoChannel; + return AlertDialog( title: Text('${loc(context).channel} — ${widget.radio.band}'), content: Column( mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -46,17 +91,45 @@ class _WifiChannelDialogState extends State { AppText.bodyMedium(loc(context).autoChannel), AppSwitch( value: _autoChannel, - onChanged: (value) => setState(() => _autoChannel = value), + // AC6: with no manual channels there is nothing to switch to, + // so the toggle is disabled and Auto is enforced. + onChanged: _hasManualChannels + ? (value) => setState(() { + if (value) { + _selected = _autoValue; + } else { + // Turning Auto OFF: restore the stored channel when + // still selectable, otherwise pick the first one. + _selected = _channels.contains(widget.radio.channel) + ? widget.radio.channel + : _channels.first; + } + }) + : null, ), ], ), AppGap.lg(), - AppTextField( - controller: _channelController, - hintText: loc(context).channelNumber, - keyboardType: TextInputType.number, - readOnly: _autoChannel, + AppDropdown( + items: items, + value: _selected, + label: loc(context).channel, + itemAsString: _labelFor, + // 2.26.1: onChanged==null disables the control (tap gesture gated). AC3. + onChanged: dropdownEnabled + ? (value) { + if (value != null) setState(() => _selected = value); + } + : null, ), + AppGap.sm(), + // Per mockup, always surface the channel the router is actually using + // — in both Auto and manual modes — whenever manual options exist. + if (!_hasManualChannels) + AppText.bodySmall(loc(context).channelNoManualOptions) + else + AppText.bodySmall( + loc(context).channelCurrentlyUsing(widget.radio.channel)), ], ), actions: [ @@ -65,15 +138,29 @@ class _WifiChannelDialogState extends State { child: Text(loc(context).cancel), ), FilledButton( - onPressed: () { - final channel = - int.tryParse(_channelController.text) ?? widget.radio.channel; - Navigator.of(context) - .pop((channel: channel, autoChannel: _autoChannel)); - }, + onPressed: _onApply, child: Text(loc(context).apply), ), ], ); } + + void _onApply() { + // AC4: if the user did not move the dropdown from where it opened, Apply is + // a no-op — return null so the caller issues no mutation. Comparing against + // the initial dropdown selection (not the radio's stored channel) means an + // unselectable stored channel — e.g. a DFS channel the firmware left set + // while DFS is off — does not read as a user change and trigger a write. + if (_selected == _initialSelected) { + Navigator.of(context).pop(); + return; + } + + final autoChannel = _autoChannel; + // When Auto is selected the concrete channel is irrelevant to firmware; + // keep the existing value so the returned record is stable. + final channel = autoChannel ? widget.radio.channel : _selected; + + Navigator.of(context).pop((channel: channel, autoChannel: autoChannel)); + } } diff --git a/lib/page/dashboard/views/usp_dashboard_view.dart b/lib/page/dashboard/views/usp_dashboard_view.dart index 3d5c82744..a6679c4ca 100644 --- a/lib/page/dashboard/views/usp_dashboard_view.dart +++ b/lib/page/dashboard/views/usp_dashboard_view.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:privacy_gui/components/localizations/service_error_localizations.dart'; +import 'package:privacy_gui/components/views/service_error_view.dart'; +import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/localization/localization_hook.dart'; import 'package:privacy_gui/page/_shared/providers/usp_bars_visible_provider.dart'; import 'package:privacy_gui/page/dashboard/orchestrator/dashboard_orchestrator.dart'; @@ -51,7 +52,15 @@ class UspDashboardView extends ConsumerWidget { loading: () => const Center( child: AppLoader(), ), - error: (error, stack) => _buildError(context, ref, error), + error: (error, stack) => ServiceErrorView( + error: error is ServiceError ? error : null, + title: loc(context).failedToLoadSettings, + onRetry: () => ref + .read(dashboardOrchestratorProvider.notifier) + .refreshAll(), + secondaryLabel: loc(context).logout, + onSecondary: () => _logout(context, ref), + ), data: (_) => const UspSliverDashboardView(), ), ), @@ -62,33 +71,6 @@ class UspDashboardView extends ConsumerWidget { ); } - Widget _buildError(BuildContext context, WidgetRef ref, Object error) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - AppIcon.font(Icons.error_outline, - size: 48, color: Theme.of(context).colorScheme.error), - AppGap.xl(), - AppText.titleMedium(loc(context).failedToLoadSettings), - AppGap.md(), - AppText.bodyMedium(localizeServiceError(context, error)), - AppGap.xxl(), - AppButton( - label: loc(context).retry, - onTap: () => - ref.read(dashboardOrchestratorProvider.notifier).refreshAll(), - ), - AppGap.md(), - AppButton.text( - label: loc(context).logout, - onTap: () => _logout(context, ref), - ), - ], - ), - ); - } - void _logout(BuildContext context, WidgetRef ref) { // Fire-and-forget logout (same pattern as JNAP general_settings_widget). // Navigate synchronously — no async gap avoids WidgetRef invalidation. diff --git a/lib/page/dashboard/views/usp_sliver_dashboard_view.dart b/lib/page/dashboard/views/usp_sliver_dashboard_view.dart index c6782273d..930df8912 100644 --- a/lib/page/dashboard/views/usp_sliver_dashboard_view.dart +++ b/lib/page/dashboard/views/usp_sliver_dashboard_view.dart @@ -6,7 +6,7 @@ import 'package:privacy_gui/page/dashboard/views/components/effects/jiggle_shake import 'package:privacy_gui/page/dashboard/factories/usp_widget_factory.dart'; import 'package:privacy_gui/constants/pref_key.dart'; import 'package:privacy_gui/page/dashboard/models/usp_dashboard_preset.dart'; -import 'package:privacy_gui/page/dashboard/models/usp_layout_preferences.dart'; +import 'package:privacy_gui/page/dashboard/providers/dashboard_edit_mode_provider.dart'; import 'package:privacy_gui/page/dashboard/models/package_widget_template.dart'; import 'package:privacy_gui/page/dashboard/providers/package_widget_loader.dart'; import 'package:privacy_gui/page/dashboard/widgets/package_widget_renderer.dart'; @@ -42,9 +42,6 @@ class UspSliverDashboardView extends ConsumerStatefulWidget { class _UspSliverDashboardViewState extends ConsumerState { - bool _isEditMode = false; - List? _initialLayoutSnapshot; - UspLayoutPreferences? _initialPrefsSnapshot; bool _presetDialogShown = false; @override @@ -112,45 +109,15 @@ class _UspSliverDashboardViewState } void _enterEditMode() async { - // Ensure preferences have been loaded from SharedPreferences before - // capturing the snapshot. Without this, the snapshot may capture the - // default state (preset = null) if _loadFromPrefs hasn't completed yet. - await ref.read(uspLayoutPreferencesProvider.notifier).initialized; - - final controller = ref.read(uspSliverDashboardControllerProvider); - _initialLayoutSnapshot = controller.exportLayout(); - _initialPrefsSnapshot = ref.read(uspLayoutPreferencesProvider); - - if (!mounted) return; - setState(() { - _isEditMode = true; - }); - controller.setEditMode(true); + await ref.read(dashboardEditModeProvider.notifier).enterEditMode(); } - void _exitEditMode({bool save = true}) async { - final controller = ref.read(uspSliverDashboardControllerProvider); - - if (!save) { - if (_initialLayoutSnapshot != null) { - controller.importLayout(_initialLayoutSnapshot!); - await ref - .read(uspSliverDashboardControllerProvider.notifier) - .saveLayout(); - } - if (_initialPrefsSnapshot != null) { - await ref - .read(uspLayoutPreferencesProvider.notifier) - .restoreSnapshot(_initialPrefsSnapshot!); - } - } + void _commitEditMode() async { + await ref.read(dashboardEditModeProvider.notifier).commitEditMode(); + } - setState(() { - _isEditMode = false; - _initialLayoutSnapshot = null; - _initialPrefsSnapshot = null; - }); - controller.setEditMode(false); + void _cancelEditMode() async { + await ref.read(dashboardEditModeProvider.notifier).cancelEditMode(); } @override @@ -205,6 +172,7 @@ class _UspSliverDashboardViewState Widget _buildHeader(BuildContext context) { final isRemoteMode = GlobalConfig.remote.isActive; + final isEditMode = ref.watch(dashboardEditModeProvider).isEditing; return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -212,7 +180,7 @@ class _UspSliverDashboardViewState AppText.headlineSmall(loc(context).uspDashboard), Row( children: [ - if (_isEditMode) ...[ + if (isEditMode) ...[ AppIconButton( icon: AppIcon.font(Icons.auto_fix_high), onTap: () { @@ -240,12 +208,12 @@ class _UspSliverDashboardViewState AppGap.sm(), AppIconButton( icon: AppIcon.font(Icons.close), - onTap: () => _exitEditMode(save: false), + onTap: _cancelEditMode, ), AppGap.sm(), AppIconButton( icon: AppIcon.font(Icons.check), - onTap: () => _exitEditMode(save: true), + onTap: _commitEditMode, ), ] else ...[ AppIconButton( @@ -339,6 +307,7 @@ class _UspSliverDashboardViewState Widget _buildSliverDashboard(BuildContext context) { final controller = ref.watch(uspSliverDashboardControllerProvider); final factory = ref.watch(uspWidgetFactoryProvider); + final isEditMode = ref.watch(dashboardEditModeProvider).isEditing; final uiKitColumns = context.currentMaxColumns; final scrollController = ScrollController(); @@ -360,18 +329,18 @@ class _UspSliverDashboardViewState controller: controller, scrollController: scrollController, itemBuilder: (context, item) { - return _buildItemWidget(context, item, _isEditMode, factory); + return _buildItemWidget(context, item, isEditMode, factory); }, slotAspectRatio: ratio, mainAxisSpacing: AppSpacing.lg, crossAxisSpacing: AppSpacing.lg, padding: EdgeInsets.symmetric(horizontal: pageMargin), - gridStyle: _isEditMode ? editModeGridStyle : null, + gridStyle: isEditMode ? editModeGridStyle : null, onItemResizeEnd: (item) { _handleResizeEnd(context, item); }, // Drag-to-trash for widget removal in edit mode. - trashBuilder: !_isEditMode + trashBuilder: !isEditMode ? null : (context, isHovered, isActive, activeItemId) { return _buildTrashZone(context, isHovered, isActive); @@ -388,13 +357,13 @@ class _UspSliverDashboardViewState padding: EdgeInsets.symmetric(horizontal: pageMargin), sliver: SliverDashboard( itemBuilder: (context, item) { - return _buildItemWidget(context, item, _isEditMode, factory); + return _buildItemWidget(context, item, isEditMode, factory); }, slotAspectRatio: ratio, mainAxisSpacing: AppSpacing.lg, crossAxisSpacing: AppSpacing.lg, breakpoints: {0: uiKitColumns}, - gridStyle: _isEditMode ? editModeGridStyle : null, + gridStyle: isEditMode ? editModeGridStyle : null, ), ), const SliverToBoxAdapter( @@ -542,15 +511,11 @@ class _UspSliverDashboardViewState ), ); - if ((result == 'reset' || - result == 'toggle_off' || - result == 'preset_changed') && - mounted) { - setState(() { - _isEditMode = false; - _initialLayoutSnapshot = null; - _initialPrefsSnapshot = null; - }); + if ((result == 'reset' || result == 'preset_changed') && mounted) { + // Commit (keep) the change — the settings panel already applied the + // reset/preset directly to the controller and prefs, so exiting must + // preserve it, not revert to the pre-edit snapshot. + await ref.read(dashboardEditModeProvider.notifier).commitEditMode(); } } @@ -578,9 +543,9 @@ class _UspSliverDashboardViewState ); // SizedBox.expand ensures cards fill their grid cell. - // ClipRect prevents content from visually overflowing the cell boundary. - final displayedWidget = - SizedBox.expand(child: ClipRect(child: resolvedWidget)); + // Note: ClipRect was removed because it clips shadows/borders causing + // visual truncation. Cards handle their own overflow via internal clipping. + final displayedWidget = SizedBox.expand(child: resolvedWidget); if (isEditMode) { // In edit mode: AbsorbPointer blocks content interactions while keeping diff --git a/lib/page/devices/cards/usp_connected_devices_card.dart b/lib/page/devices/cards/usp_connected_devices_card.dart index 4bfdb91d7..c59a7cebe 100644 --- a/lib/page/devices/cards/usp_connected_devices_card.dart +++ b/lib/page/devices/cards/usp_connected_devices_card.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:privacy_gui/localization/localization_hook.dart'; import 'package:privacy_gui/page/_shared/utils/device_classifier.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; import 'package:privacy_gui/page/_shared/components/card_skeleton.dart'; import 'package:privacy_gui/page/_shared/components/dashboard_card_template.dart'; @@ -14,7 +14,7 @@ import 'package:ui_kit_library/ui_kit.dart'; import 'package:privacy_gui/page/_shared/components/usp_status_dot.dart'; class UspConnectedDevicesCard extends ConsumerWidget { - final List? devices; + final List? devices; const UspConnectedDevicesCard({ super.key, @@ -110,7 +110,7 @@ class UspConnectedDevicesCard extends ConsumerWidget { ); } - Widget _buildDeviceRow(BuildContext context, DeviceUIModel device) { + Widget _buildDeviceRow(BuildContext context, ClientDevice device) { final scheme = Theme.of(context).colorScheme; final deviceCategory = DeviceClassifier.classify( hostname: device.hostName, diff --git a/lib/page/devices/providers/device_detail_provider.dart b/lib/page/devices/providers/device_detail_provider.dart index 28c027e7e..9e2a51555 100644 --- a/lib/page/devices/providers/device_detail_provider.dart +++ b/lib/page/devices/providers/device_detail_provider.dart @@ -1,7 +1,7 @@ import 'package:collection/collection.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; import 'package:privacy_gui/page/_shared/models/dhcp_reservation_ui_model.dart'; import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; import 'package:privacy_gui/page/local_network/providers/dhcp_data_provider.dart'; @@ -11,7 +11,7 @@ final uspDeviceDetailProvider = Provider.family((ref, mac) { final data = ref.watch(devicesDataProvider).valueOrNull; if (data == null) return DeviceDetailState.empty(); - final device = data.deviceModels.firstWhereOrNull( + final device = data.clientDevices.firstWhereOrNull( (d) => d.mac.toUpperCase() == mac.toUpperCase(), ); final dhcpData = ref.watch(dhcpDataProvider).valueOrNull; @@ -23,7 +23,7 @@ final uspDeviceDetailProvider = /// Aggregated state for a single device's detail page. class DeviceDetailState extends Equatable { - final DeviceUIModel? device; + final ClientDevice? device; final DhcpReservationUIModel? reservation; const DeviceDetailState({this.device, this.reservation}); diff --git a/lib/page/devices/providers/device_filter_provider.dart b/lib/page/devices/providers/device_filter_provider.dart index 2d08f6a81..b874cf5e0 100644 --- a/lib/page/devices/providers/device_filter_provider.dart +++ b/lib/page/devices/providers/device_filter_provider.dart @@ -1,43 +1,30 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacy_gui/core/utils/oui_lookup.dart'; import 'package:privacy_gui/core/utils/wifi.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; +import 'package:privacy_gui/page/_shared/utils/device_classifier.dart'; import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; import 'package:privacy_gui/page/devices/providers/device_filter_state.dart'; -/// Bucket a signal strength (dBm) into a `DeviceSignalFilter` value. -/// Delegates to [getWifiSignalLevel] so the filter, the list-tile indicator, -/// and every other project surface share the same RSSI thresholds -/// (-65 / -71 / -78 dBm for excellent / good / fair). -DeviceSignalFilter signalBucketOf(int? rssi) { - if (rssi == null) return DeviceSignalFilter.unknown; +DeviceSignalLevel signalLevelOf(int rssi) { return switch (getWifiSignalLevel(rssi)) { - NodeSignalLevel.excellent => DeviceSignalFilter.excellent, - NodeSignalLevel.good => DeviceSignalFilter.good, - NodeSignalLevel.fair => DeviceSignalFilter.fair, - NodeSignalLevel.poor || NodeSignalLevel.none => DeviceSignalFilter.poor, - // `wired` only returns when rssi is null, which we handled above. - NodeSignalLevel.wired => DeviceSignalFilter.unknown, + NodeSignalLevel.excellent => DeviceSignalLevel.excellent, + NodeSignalLevel.good => DeviceSignalLevel.good, + NodeSignalLevel.fair => DeviceSignalLevel.fair, + NodeSignalLevel.poor || NodeSignalLevel.none => DeviceSignalLevel.poor, + NodeSignalLevel.wired => DeviceSignalLevel.poor, }; } -/// Map a `DeviceSignalFilter` bucket back to the canonical [NodeSignalLevel] -/// so UI can reuse [NodeSignalLevelExt.resolveLabel] / `resolveColor` and -/// stay consistent with the node/topology pages. -NodeSignalLevel? nodeLevelOf(DeviceSignalFilter bucket) { - return switch (bucket) { - DeviceSignalFilter.all => null, - DeviceSignalFilter.excellent => NodeSignalLevel.excellent, - DeviceSignalFilter.good => NodeSignalLevel.good, - DeviceSignalFilter.fair => NodeSignalLevel.fair, - DeviceSignalFilter.poor => NodeSignalLevel.poor, - DeviceSignalFilter.unknown => null, +NodeSignalLevel? nodeLevelOf(DeviceSignalLevel level) { + return switch (level) { + DeviceSignalLevel.excellent => NodeSignalLevel.excellent, + DeviceSignalLevel.good => NodeSignalLevel.good, + DeviceSignalLevel.fair => NodeSignalLevel.fair, + DeviceSignalLevel.poor => NodeSignalLevel.poor, }; } -/// User-selected filter configuration. Hosted in a notifier so that all -/// cross-field dependency resets (e.g. Status=Offline clears everything else) -/// and orphan reconciliation (e.g. selected SSID disappears after SSE refresh) -/// happen in one place rather than being duplicated at every call site. final deviceFilterConfigProvider = StateNotifierProvider((ref) { return DeviceFilterNotifier(ref); @@ -45,9 +32,6 @@ final deviceFilterConfigProvider = class DeviceFilterNotifier extends StateNotifier { DeviceFilterNotifier(this._ref) : super(const DeviceFilterConfig()) { - // Reconcile when the underlying option set changes (SSE refresh, devices - // join/leave, node drops). Clears any field that no longer has a matching - // option so the UI never shows an invisible active filter. _ref.listen( deviceFilterOptionsProvider, (_, options) => _reconcile(options), @@ -61,88 +45,184 @@ class DeviceFilterNotifier extends StateNotifier { state = state.copyWith(searchQuery: value); } - /// Status flips are the widest-blast-radius change: Offline collapses every - /// other dimension because offline devices have no live SSID/band/RSSI/node. void setStatus(DeviceStatusFilter value) { if (value == DeviceStatusFilter.offline) { state = state.copyWith( status: value, - connection: DeviceConnectionFilter.all, - signal: DeviceSignalFilter.all, - nodeId: () => null, - ssidName: () => null, - band: () => null, + connections: const {}, + signals: const {}, + includeUnknownSignal: false, + nodeIds: () => const {}, + ssidNames: () => const {}, + bands: () => const {}, ); return; } state = state.copyWith(status: value); } - /// Picking Ethernet invalidates every WiFi-only dimension; picking WiFi or - /// All does not need to clear anything (Ethernet filter was never gating - /// them). - void setConnection(DeviceConnectionFilter value) { - if (value == DeviceConnectionFilter.ethernet) { + void setConnections(Set values) { + final isEthernetOnly = + values.length == 1 && values.contains(ConnectionType.wired); + if (isEthernetOnly) { state = state.copyWith( - connection: value, - signal: DeviceSignalFilter.all, - ssidName: () => null, - band: () => null, + connections: values, + signals: const {}, + includeUnknownSignal: false, + ssidNames: () => const {}, + bands: () => const {}, ); return; } - state = state.copyWith(connection: value); + state = state.copyWith(connections: values); } - void setSignal(DeviceSignalFilter value) { - state = state.copyWith(signal: value); + void toggleConnection(ConnectionType type) { + var next = Set.from(state.connections); + if (next.contains(type)) { + next.remove(type); + } else { + next.add(type); + } + setConnections(next); + } + + void setSignals(Set values) { + state = state.copyWith(signals: values); + } + + void toggleSignal(DeviceSignalLevel level) { + var next = Set.from(state.signals); + if (next.contains(level)) { + next.remove(level); + } else { + next.add(level); + } + state = state.copyWith(signals: next); + } + + void setIncludeUnknownSignal(bool value) { + state = state.copyWith(includeUnknownSignal: value); + } + + void setNodeIds(Set values) { + state = state.copyWith(nodeIds: () => values); + } + + void toggleNodeId(String nodeId) { + var next = Set.from(state.nodeIds); + if (next.contains(nodeId)) { + next.remove(nodeId); + } else { + next.add(nodeId); + } + state = state.copyWith(nodeIds: () => next); } - void setNodeId(String? value) { - state = state.copyWith(nodeId: () => value); + void setSsidNames(Set values) { + state = state.copyWith(ssidNames: () => values); } - void setSsidName(String? value) { - state = state.copyWith(ssidName: () => value); + void toggleSsidName(String ssid) { + var next = Set.from(state.ssidNames); + if (next.contains(ssid)) { + next.remove(ssid); + } else { + next.add(ssid); + } + state = state.copyWith(ssidNames: () => next); + } + + void setBands(Set values) { + state = state.copyWith(bands: () => values); } - void setBand(String? value) { - state = state.copyWith(band: () => value); + void toggleBand(String band) { + var next = Set.from(state.bands); + if (next.contains(band)) { + next.remove(band); + } else { + next.add(band); + } + state = state.copyWith(bands: () => next); + } + + void setDeviceCategories(Set values) { + state = state.copyWith(deviceCategories: values); + } + + void toggleDeviceCategory(DeviceCategory category) { + var next = Set.from(state.deviceCategories); + if (next.contains(category)) { + next.remove(category); + } else { + next.add(category); + } + state = state.copyWith(deviceCategories: next); + } + + void setPrivateMac(PrivateMacFilter value) { + state = state.copyWith(privateMac: value); } void clearAll() { state = state.copyWith( searchQuery: '', status: DeviceStatusFilter.all, - connection: DeviceConnectionFilter.all, - signal: DeviceSignalFilter.all, - nodeId: () => null, - ssidName: () => null, - band: () => null, + connections: const {}, + deviceCategories: const {}, + privateMac: PrivateMacFilter.all, + signals: const {}, + includeUnknownSignal: false, + nodeIds: () => const {}, + ssidNames: () => const {}, + bands: () => const {}, ); } void _reconcile(DeviceFilterOptions options) { var next = state; - if (next.nodeId != null && - !options.nodes.any((n) => n.deviceId == next.nodeId)) { - next = next.copyWith(nodeId: () => null); + + if (next.nodeIds.isNotEmpty) { + final validNodeIds = options.nodes.map((n) => n.deviceId).toSet(); + final filtered = next.nodeIds.intersection(validNodeIds); + if (filtered.length != next.nodeIds.length) { + next = next.copyWith(nodeIds: () => filtered); + } } - if (next.ssidName != null && !options.ssids.contains(next.ssidName)) { - next = next.copyWith(ssidName: () => null); + + if (next.ssidNames.isNotEmpty) { + final validSsids = options.ssids.toSet(); + final filtered = next.ssidNames.intersection(validSsids); + if (filtered.length != next.ssidNames.length) { + next = next.copyWith(ssidNames: () => filtered); + } } - if (next.band != null && !options.bands.contains(next.band)) { - next = next.copyWith(band: () => null); + + if (next.bands.isNotEmpty) { + final validBands = options.bands.toSet(); + final filtered = next.bands.intersection(validBands); + if (filtered.length != next.bands.length) { + next = next.copyWith(bands: () => filtered); + } + } + + if (next.includeUnknownSignal && !options.hasUnknownSignalDevices) { + next = next.copyWith(includeUnknownSignal: false); } - if (next.signal == DeviceSignalFilter.unknown && - !options.hasUnknownSignalDevices) { - next = next.copyWith(signal: DeviceSignalFilter.all); + + if (next.deviceCategories.isNotEmpty) { + final validCategories = options.deviceCategories.toSet(); + final filtered = next.deviceCategories.intersection(validCategories); + if (filtered.length != next.deviceCategories.length) { + next = next.copyWith(deviceCategories: filtered); + } } + if (next != state) state = next; } } -/// Available filter options derived from current device data. final deviceFilterOptionsProvider = Provider((ref) { final data = ref.watch(devicesDataProvider).valueOrNull; if (data == null) return const DeviceFilterOptions(); @@ -163,20 +243,25 @@ final deviceFilterOptionsProvider = Provider((ref) { .toSet() .toList() ..sort(), + deviceCategories: devices + .map((d) => DeviceClassifier.classify(hostname: d.hostName, mac: d.mac)) + .toSet() + .toList() + ..sort((a, b) => a.index.compareTo(b.index)), hasUnknownSignalDevices: devices.any((d) => d.isWifi && d.signalStrength == null), ); }); /// Filtered device list — applies every active dimension + search. -final filteredDeviceListProvider = Provider>((ref) { +final filteredDeviceListProvider = Provider>((ref) { final data = ref.watch(devicesDataProvider).valueOrNull; if (data == null) return []; final filter = ref.watch(deviceFilterConfigProvider); return data.clientDevices.where((d) => _matches(d, filter)).toList(); }); -bool _matches(DeviceUIModel device, DeviceFilterConfig filter) { +bool _matches(ClientDevice device, DeviceFilterConfig filter) { // Status. if (filter.status == DeviceStatusFilter.online && !device.isActive) { return false; @@ -185,47 +270,84 @@ bool _matches(DeviceUIModel device, DeviceFilterConfig filter) { return false; } - // Connection type (WiFi vs. Ethernet). - if (filter.connection == DeviceConnectionFilter.wifi && !device.isWifi) { - return false; + // Connection type (multi-select OR) + if (filter.connections.isNotEmpty) { + final deviceType = + device.isWifi ? ConnectionType.wifi : ConnectionType.wired; + if (!filter.connections.contains(deviceType)) { + return false; + } } - if (filter.connection == DeviceConnectionFilter.ethernet && device.isWifi) { - return false; + + // BUG FIX: Exclude Ethernet when WiFi-specific filters are active + if (filter.hasWifiOnlyFilter && !device.isWifi) { + if (!filter.connections.contains(ConnectionType.wired)) { + return false; + } } - // Node. Offline devices lose their parentNodeId (mesh STA table only lists - // currently associated clients), so node filter must pass them through — - // otherwise Status=All + Node=X would silently drop every offline device. - if (filter.nodeId != null && - device.isActive && - device.parentNodeId != filter.nodeId) { - return false; + // Device category (multi-select OR) + if (filter.deviceCategories.isNotEmpty) { + final category = + DeviceClassifier.classify(hostname: device.hostName, mac: device.mac); + if (!filter.deviceCategories.contains(category)) { + return false; + } } - // Signal. Ethernet and null-RSSI WiFi devices pass through when a specific - // level is selected; `unknown` is the explicit opt-in bucket for null-RSSI - // WiFi devices. - if (filter.signal != DeviceSignalFilter.all) { - if (filter.signal == DeviceSignalFilter.unknown) { - if (!device.isWifi || device.signalStrength != null) return false; - } else if (device.isWifi && device.signalStrength != null) { - if (signalBucketOf(device.signalStrength) != filter.signal) return false; + // Private MAC filter + if (filter.privateMac != PrivateMacFilter.all) { + final isPrivate = OuiLookup.isRandomizedMac(device.mac); + if (filter.privateMac == PrivateMacFilter.privateOnly && !isPrivate) { + return false; + } + if (filter.privateMac == PrivateMacFilter.publicOnly && isPrivate) { + return false; } } - // SSID — WiFi-only dimension, Ethernet passes through. - if (filter.ssidName != null && - device.isWifi && - device.ssidName != filter.ssidName) { - return false; + // Node (multi-select OR). Offline devices pass through. + if (filter.nodeIds.isNotEmpty && device.isActive) { + if (device.parentNodeId == null || + !filter.nodeIds.contains(device.parentNodeId)) { + return false; + } } - // Band — WiFi-only dimension, Ethernet passes through. - if (filter.band != null && device.isWifi && device.band != filter.band) { - return false; + // Signal (multi-select OR + unknown toggle) + if (filter.signals.isNotEmpty || filter.includeUnknownSignal) { + if (device.isWifi) { + if (device.signalStrength == null) { + if (!filter.includeUnknownSignal) return false; + } else { + // Device has known signal strength + if (filter.signals.isEmpty) { + // Only unknown signals requested, exclude devices with known signal + return false; + } + if (!filter.signals.contains(signalLevelOf(device.signalStrength!))) { + return false; + } + } + } + } + + // SSID (multi-select OR) + if (filter.ssidNames.isNotEmpty && device.isWifi) { + if (device.ssidName == null || + !filter.ssidNames.contains(device.ssidName)) { + return false; + } + } + + // Band (multi-select OR) + if (filter.bands.isNotEmpty && device.isWifi) { + if (device.band == null || !filter.bands.contains(device.band)) { + return false; + } } - // Search query — match hostname, MAC, or IP (case-insensitive). + // Search query if (filter.searchQuery.isNotEmpty) { final q = filter.searchQuery.toLowerCase(); final matchesHostName = device.hostName.toLowerCase().contains(q); diff --git a/lib/page/devices/providers/device_filter_state.dart b/lib/page/devices/providers/device_filter_state.dart index c9101ca1d..42d61f829 100644 --- a/lib/page/devices/providers/device_filter_state.dart +++ b/lib/page/devices/providers/device_filter_state.dart @@ -1,80 +1,98 @@ import 'package:equatable/equatable.dart'; -import 'package:privacy_gui/page/topology/models/node_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; +import 'package:privacy_gui/page/_shared/utils/device_classifier.dart'; enum DeviceStatusFilter { all, online, offline } -enum DeviceConnectionFilter { all, wifi, ethernet } +enum DeviceSignalLevel { excellent, good, fair, poor } -/// Signal-quality buckets, thresholds aligned with `DeviceUIModel.signalLevel`. -/// `unknown` exists so that WiFi devices without RSSI data (rare firmware -/// state) can still be addressed explicitly, matching the `--` picker option -/// surfaced in the UI only when such devices are present. -enum DeviceSignalFilter { all, excellent, good, fair, poor, unknown } +enum PrivateMacFilter { all, privateOnly, publicOnly } -/// User-selected filter configuration for the device list. class DeviceFilterConfig extends Equatable { final String searchQuery; final DeviceStatusFilter status; - final DeviceConnectionFilter connection; - final DeviceSignalFilter signal; - final String? nodeId; - final String? ssidName; - final String? band; + final Set connections; + final Set deviceCategories; + final PrivateMacFilter privateMac; + final Set signals; + final bool includeUnknownSignal; + final Set nodeIds; + final Set ssidNames; + final Set bands; const DeviceFilterConfig({ this.searchQuery = '', this.status = DeviceStatusFilter.all, - this.connection = DeviceConnectionFilter.all, - this.signal = DeviceSignalFilter.all, - this.nodeId, - this.ssidName, - this.band, + this.connections = const {}, + this.deviceCategories = const {}, + this.privateMac = PrivateMacFilter.all, + this.signals = const {}, + this.includeUnknownSignal = false, + this.nodeIds = const {}, + this.ssidNames = const {}, + this.bands = const {}, }); - /// Count of active filter dimensions (excluding search). int get activeCount { var count = 0; if (status != DeviceStatusFilter.all) count++; - if (connection != DeviceConnectionFilter.all) count++; - if (signal != DeviceSignalFilter.all) count++; - if (nodeId != null) count++; - if (ssidName != null) count++; - if (band != null) count++; + if (connections.isNotEmpty) count++; + if (deviceCategories.isNotEmpty) count++; + if (privateMac != PrivateMacFilter.all) count++; + if (signals.isNotEmpty || includeUnknownSignal) count++; + if (nodeIds.isNotEmpty) count++; + if (ssidNames.isNotEmpty) count++; + if (bands.isNotEmpty) count++; return count; } - /// Count of active filter dimensions excluding status (for filter panel badge). - /// Status is displayed separately above the list, so the panel badge should - /// only reflect the "additional" filters. int get activeCountExcludingStatus { var count = 0; - if (connection != DeviceConnectionFilter.all) count++; - if (signal != DeviceSignalFilter.all) count++; - if (nodeId != null) count++; - if (ssidName != null) count++; - if (band != null) count++; + if (connections.isNotEmpty) count++; + if (deviceCategories.isNotEmpty) count++; + if (privateMac != PrivateMacFilter.all) count++; + if (signals.isNotEmpty || includeUnknownSignal) count++; + if (nodeIds.isNotEmpty) count++; + if (ssidNames.isNotEmpty) count++; + if (bands.isNotEmpty) count++; return count; } bool get isActive => activeCount > 0 || searchQuery.isNotEmpty; + bool get hasWifiOnlyFilter => + signals.isNotEmpty || + includeUnknownSignal || + ssidNames.isNotEmpty || + bands.isNotEmpty; + + bool get isEthernetOnly => + connections.length == 1 && connections.contains(ConnectionType.wired); + DeviceFilterConfig copyWith({ String? searchQuery, DeviceStatusFilter? status, - DeviceConnectionFilter? connection, - DeviceSignalFilter? signal, - String? Function()? nodeId, - String? Function()? ssidName, - String? Function()? band, + Set? connections, + Set? deviceCategories, + PrivateMacFilter? privateMac, + Set? signals, + bool? includeUnknownSignal, + Set Function()? nodeIds, + Set Function()? ssidNames, + Set Function()? bands, }) { return DeviceFilterConfig( searchQuery: searchQuery ?? this.searchQuery, status: status ?? this.status, - connection: connection ?? this.connection, - signal: signal ?? this.signal, - nodeId: nodeId != null ? nodeId() : this.nodeId, - ssidName: ssidName != null ? ssidName() : this.ssidName, - band: band != null ? band() : this.band, + connections: connections ?? this.connections, + deviceCategories: deviceCategories ?? this.deviceCategories, + privateMac: privateMac ?? this.privateMac, + signals: signals ?? this.signals, + includeUnknownSignal: includeUnknownSignal ?? this.includeUnknownSignal, + nodeIds: nodeIds != null ? nodeIds() : this.nodeIds, + ssidNames: ssidNames != null ? ssidNames() : this.ssidNames, + bands: bands != null ? bands() : this.bands, ); } @@ -82,31 +100,33 @@ class DeviceFilterConfig extends Equatable { List get props => [ searchQuery, status, - connection, - signal, - nodeId, - ssidName, - band, + connections, + deviceCategories, + privateMac, + signals, + includeUnknownSignal, + nodeIds, + ssidNames, + bands, ]; } -/// Available filter options derived from current dashboard data. class DeviceFilterOptions extends Equatable { - final List nodes; + final List nodes; final List ssids; final List bands; - - /// True when at least one WiFi device in the current list has no RSSI. - /// Controls whether the `--` (unknown) signal option is offered. + final List deviceCategories; final bool hasUnknownSignalDevices; const DeviceFilterOptions({ this.nodes = const [], this.ssids = const [], this.bands = const [], + this.deviceCategories = const [], this.hasUnknownSignalDevices = false, }); @override - List get props => [nodes, ssids, bands, hasUnknownSignalDevices]; + List get props => + [nodes, ssids, bands, deviceCategories, hasUnknownSignalDevices]; } diff --git a/lib/page/devices/providers/devices_data_provider.dart b/lib/page/devices/providers/devices_data_provider.dart index e1dea64fb..f2a85d332 100644 --- a/lib/page/devices/providers/devices_data_provider.dart +++ b/lib/page/devices/providers/devices_data_provider.dart @@ -4,74 +4,94 @@ import 'package:equatable/equatable.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:privacy_gui/core/utils/logger.dart'; import 'package:privacy_gui/core/usp/providers/sse_invalidation_provider.dart'; +import 'package:privacy_gui/framework/diagnostic_loggable.dart'; import 'package:privacy_gui/page/admin/providers/system_info_data_provider.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_network.dart'; import 'package:privacy_gui/page/_shared/models/mesh_topology_info.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; import 'package:privacy_gui/page/devices/services/usp_devices_data_service.dart'; -import 'package:privacy_gui/page/topology/models/node_ui_model.dart'; import 'package:privacy_gui/page/wifi_settings/providers/wifi_data_provider.dart'; +import 'package:privacy_gui/page/wifi_settings/services/usp_wifi_data_service.dart'; // Re-export so existing consumers can still import DevicesCodegenContext from here. export 'package:privacy_gui/page/devices/services/usp_devices_data_service.dart' show DevicesCodegenContext; // --------------------------------------------------------------------------- -// Data Model (Layer 1 — UIModel only) +// Data Model (Layer 1 — MeshNetwork as SSoT) // --------------------------------------------------------------------------- -class DevicesData extends Equatable { +class DevicesData extends Equatable with DiagnosticLoggable { final DevicesCodegenContext codegenContext; final MeshTopologyInfo meshTopology; - // UI models (computed from raw + cross-domain enrichment) - final List deviceModels; - final List nodeModels; - /// Pre-computed MAC → hostname map for DHCP hostname enrichment. final Map hostNameByMac; + /// Unified MeshNetwork container (SSoT for nodes and clients). + final MeshNetwork meshNetwork; + const DevicesData({ this.codegenContext = DevicesCodegenContext.empty, this.meshTopology = MeshTopologyInfo.empty, - this.deviceModels = const [], - this.nodeModels = const [], this.hostNameByMac = const {}, + required this.meshNetwork, }); - /// Client devices only (excludes mesh nodes: master/slave). - List get clientDevices => deviceModels - .where((d) => d.deviceRole != 'master' && d.deviceRole != 'slave') - .toList(); + /// All client devices. + List get clientDevices => meshNetwork.allClients; + + /// All mesh nodes (master + slaves). + List get nodes => meshNetwork.allNodes; + + /// Master node. + MasterNode get master => meshNetwork.master; + + /// Slave nodes. + List get slaves => meshNetwork.slaves; /// Count of online client devices. - int get onlineClientCount => clientDevices.where((d) => d.isActive).length; + int get onlineClientCount => meshNetwork.onlineClientCount; /// Total count of client devices. - int get totalClientCount => clientDevices.length; + int get totalClientCount => meshNetwork.totalClientCount; + + /// Whether this is a mesh network (has slave nodes). + bool get hasMesh => meshNetwork.hasMesh; DevicesData copyWith({ DevicesCodegenContext? codegenContext, MeshTopologyInfo? meshTopology, - List? deviceModels, - List? nodeModels, Map? hostNameByMac, + MeshNetwork? meshNetwork, }) { return DevicesData( codegenContext: codegenContext ?? this.codegenContext, meshTopology: meshTopology ?? this.meshTopology, - deviceModels: deviceModels ?? this.deviceModels, - nodeModels: nodeModels ?? this.nodeModels, hostNameByMac: hostNameByMac ?? this.hostNameByMac, + meshNetwork: meshNetwork ?? this.meshNetwork, ); } + @override + String get diagnosticName => 'DevicesData'; + + @override + Map get namedProps => { + 'meshTopology': meshTopology, + 'meshNetwork': meshNetwork, + 'hostNameByMac': hostNameByMac, + }; + + // Explicit props override for reliable equality (includes the opaque + // codegenContext). namedProps is kept lean for diagnostic JSON output. @override List get props => [ codegenContext, - meshTopology.nodes.length, - deviceModels, - nodeModels, - hostNameByMac.length, + meshTopology, + hostNameByMac, + meshNetwork, ]; } @@ -100,7 +120,7 @@ class DevicesDataNotifier extends AsyncNotifier { } }); - // WiFi data changes → rebuild deviceModels with updated enrichment. + // WiFi data changes → rebuild MeshNetwork with updated enrichment. ref.listen(wifiDataProvider, (_, next) { final wd = next.valueOrNull; final cur = state.valueOrNull; @@ -113,7 +133,7 @@ class DevicesDataNotifier extends AsyncNotifier { 'Router'; final sysInfo = ref.read(systemInfoDataProvider).valueOrNull?.model; - final rebuilt = svc.rebuildWithWifiData( + final meshNetwork = svc.rebuildWithWifiData( context: cur.codegenContext, wifiClientMap: wd.wifiClientMap, connectionDetailMap: wd.connectionDetailMap, @@ -122,10 +142,7 @@ class DevicesDataNotifier extends AsyncNotifier { systemInfo: sysInfo, ); - state = AsyncData(cur.copyWith( - deviceModels: rebuilt.deviceModels, - nodeModels: rebuilt.nodeModels, - )); + state = AsyncData(cur.copyWith(meshNetwork: meshNetwork)); }); ref.onDispose(() => _debounce?.cancel()); @@ -159,9 +176,9 @@ class DevicesDataNotifier extends AsyncNotifier { systemInfo: sysData?.model, ); - logger.d('[USP][DevicesData]: Fetched — ' - 'deviceModels: ${result.deviceModels.length}, ' - 'nodeModels: ${result.nodeModels.length}'); + logger.t('[USP][DevicesData]: Fetched — ' + 'clients: ${result.meshNetwork.totalClientCount}, ' + 'nodes: ${result.meshNetwork.allNodes.length}'); // Preserve existing mesh topology during refetch to avoid UI flicker. // Fire-and-forget will update it shortly after. @@ -174,9 +191,8 @@ class DevicesDataNotifier extends AsyncNotifier { return DevicesData( codegenContext: result.codegenContext, meshTopology: existingMesh, - deviceModels: result.deviceModels, - nodeModels: result.nodeModels, hostNameByMac: result.hostNameByMac, + meshNetwork: result.meshNetwork, ); } @@ -188,13 +204,22 @@ class DevicesDataNotifier extends AsyncNotifier { SystemInfoData? sysData, DevicesDataFetchResult fetchResult, ) async { - final meshTopology = await svc.fetchMeshTopology(); + // Build BSSID → band mapping for slave client band resolution + final wifiCodegen = wifiData.codegenContext.raw; + final bssidToBandMap = UspWifiDataService.buildBssidToBandMap( + ssids: wifiCodegen.ssids, + radios: wifiCodegen.radios, + ); + + final meshTopology = await svc.fetchMeshTopology( + bssidToBandMap: bssidToBandMap, + ); if (meshTopology.isEmpty) return; final cur = state.valueOrNull; if (cur == null) return; - final rebuilt = svc.rebuildWithMesh( + final meshNetwork = svc.rebuildWithMesh( context: cur.codegenContext, wifiClientMap: wifiData.wifiClientMap, connectionDetailMap: wifiData.connectionDetailMap, @@ -203,15 +228,13 @@ class DevicesDataNotifier extends AsyncNotifier { systemInfo: sysData?.model, ); - logger.d('[USP][DevicesData]: Mesh update — ' + logger.t('[USP][DevicesData]: Mesh update — ' 'meshNodes: ${meshTopology.nodes.length}, ' - 'nodeModels: ${rebuilt.nodeModels.length}, ' - 'deviceModels: ${rebuilt.deviceModels.length}'); + 'clients: ${meshNetwork.totalClientCount}'); state = AsyncData(cur.copyWith( meshTopology: meshTopology, - deviceModels: rebuilt.deviceModels, - nodeModels: rebuilt.nodeModels, + meshNetwork: meshNetwork, )); } @@ -227,9 +250,6 @@ class DevicesDataNotifier extends AsyncNotifier { Future _refetchPreservingMesh() async { final currentState = state.valueOrNull; final existingMesh = currentState?.meshTopology ?? MeshTopologyInfo.empty; - logger.d('[USP][DevicesData]: _refetchPreservingMesh — ' - 'currentState: ${currentState != null}, ' - 'existingMesh nodes: ${existingMesh.nodes.length}'); final svc = ref.read(uspDevicesDataServiceProvider); @@ -257,8 +277,8 @@ class DevicesDataNotifier extends AsyncNotifier { ); // Rebuild with existing mesh to preserve slave node visibility. - final rebuilt = existingMesh.isEmpty - ? (deviceModels: result.deviceModels, nodeModels: result.nodeModels) + final meshNetwork = existingMesh.isEmpty + ? result.meshNetwork : svc.rebuildWithMesh( context: result.codegenContext, wifiClientMap: wifiData.wifiClientMap, @@ -268,18 +288,16 @@ class DevicesDataNotifier extends AsyncNotifier { systemInfo: sysData?.model, ); - logger.d('[USP][DevicesData]: Refetch (preserve mesh) — ' - 'deviceModels: ${rebuilt.deviceModels.length}, ' - 'nodeModels: ${rebuilt.nodeModels.length}, ' + logger.t('[USP][DevicesData]: Refetch (preserve mesh) — ' + 'clients: ${meshNetwork.totalClientCount}, ' 'existingMesh: ${existingMesh.nodes.length}'); // Update state with new device data but preserve existing mesh topology. state = AsyncData(DevicesData( codegenContext: result.codegenContext, meshTopology: existingMesh, - deviceModels: rebuilt.deviceModels, - nodeModels: rebuilt.nodeModels, hostNameByMac: result.hostNameByMac, + meshNetwork: meshNetwork, )); // Fire-and-forget: fetch mesh topology in background, then update state. diff --git a/lib/page/devices/services/usp_devices_data_service.dart b/lib/page/devices/services/usp_devices_data_service.dart index 41cd76df1..9932bc1b5 100644 --- a/lib/page/devices/services/usp_devices_data_service.dart +++ b/lib/page/devices/services/usp_devices_data_service.dart @@ -6,14 +6,14 @@ import 'package:privacy_gui/core/usp/errors/usp_error.dart'; import 'package:privacy_gui/generated/connected_devices.g.dart'; import 'package:privacy_gui/core/usp/providers/usp_client_provider.dart'; import 'package:privacy_gui/core/usp/services/usp_client.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_network.dart'; import 'package:privacy_gui/page/_shared/models/system_info_ui_model.dart'; import 'package:privacy_gui/page/_shared/models/mesh_topology_info.dart'; import 'package:privacy_gui/page/_shared/models/wifi_client_ui_model.dart'; import 'package:privacy_gui/page/_shared/models/client_connection_detail.dart'; import 'package:privacy_gui/page/_shared/utils/mesh_topology_builder.dart'; +import 'package:privacy_gui/page/_shared/utils/mesh_network_builder.dart'; import 'package:privacy_gui/generated/data_elements_network.g.dart'; -import 'package:privacy_gui/page/topology/models/node_ui_model.dart'; // --------------------------------------------------------------------------- // Provider @@ -57,15 +57,15 @@ class DevicesCodegenContext extends Equatable { /// Result of a devices data fetch, returned by [UspDevicesDataService.fetch]. class DevicesDataFetchResult { final DevicesCodegenContext codegenContext; - final List deviceModels; - final List nodeModels; final Map hostNameByMac; + /// Unified MeshNetwork container (SSoT for nodes and clients). + final MeshNetwork meshNetwork; + const DevicesDataFetchResult({ required this.codegenContext, - required this.deviceModels, - required this.nodeModels, required this.hostNameByMac, + required this.meshNetwork, }); } @@ -103,27 +103,20 @@ class UspDevicesDataService { final hostNameByMac = _buildHostNameMap(connectedDevices); - final deviceModels = _buildDeviceUIModels( + // Build MeshNetwork (SSoT for nodes and clients) + final meshNetwork = MeshNetworkBuilder.build( connectedDevices: connectedDevices, wifiClientMap: wifiClientMap, connectionDetailMap: connectionDetailMap, meshTopology: MeshTopologyInfo.empty, gatewayName: gatewayName, + systemInfo: systemInfo, ); - final nodeModels = systemInfo != null - ? _buildNodeUIModels( - meshTopology: MeshTopologyInfo.empty, - deviceModels: deviceModels, - systemInfo: systemInfo, - ) - : []; - return DevicesDataFetchResult( codegenContext: context, - deviceModels: deviceModels, - nodeModels: nodeModels, hostNameByMac: hostNameByMac, + meshNetwork: meshNetwork, ); } @@ -134,9 +127,15 @@ class UspDevicesDataService { /// to codegen [DataElementsNetwork.fetch], then transforms the tree /// into a flat [MeshTopologyInfo] with client→node mapping. /// + /// [bssidToBandMap] is a BSSID → band mapping for resolving band info + /// for clients on slave nodes. Build it via + /// [UspWifiDataService.buildBssidToBandMap]. + /// /// Returns [MeshTopologyInfo.empty] if the router doesn't support /// DataElements or the subtree is empty (non-mesh / single router). - Future fetchMeshTopology() async { + Future fetchMeshTopology({ + Map bssidToBandMap = const {}, + }) async { try { final network = await DataElementsNetwork.fetch(_usp); if (network.items.isEmpty) { @@ -144,7 +143,7 @@ class UspDevicesDataService { '[USP][Dashboard]: DataElements empty — not a mesh or unsupported'); return MeshTopologyInfo.empty; } - return _buildTopologyInfo(network); + return _buildTopologyInfo(network, bssidToBandMap); } catch (e) { logger.d( '[USP][Dashboard]: DataElements not supported or fetch failed: $e'); @@ -152,19 +151,25 @@ class UspDevicesDataService { } } - MeshTopologyInfo _buildTopologyInfo(DataElementsNetwork network) { - final result = MeshTopologyBuilder.build(network); - logger.d('[USP][Dashboard]: Mesh nodes: ${result.nodes.length}, ' - 'client→node mappings: ${result.clientToNodeMap.length}'); + MeshTopologyInfo _buildTopologyInfo( + DataElementsNetwork network, + Map bssidToBandMap, + ) { + final result = MeshTopologyBuilder.build( + network, + bssidToBandMap: bssidToBandMap, + ); + logger.t('[USP][Dashboard]: Mesh nodes: ${result.nodes.length}, ' + 'client→node mappings: ${result.clientToNodeMap.length}, ' + 'band/SSID mappings: ${result.clientBandSsidMap.length}'); return result; } - /// Rebuilds device + node UI models with updated WiFi enrichment data. + /// Rebuilds MeshNetwork with updated WiFi enrichment data. /// /// Called by the provider's WiFi listener for incremental rebuild /// without re-fetching ConnectedDevices. - ({List deviceModels, List nodeModels}) - rebuildWithWifiData({ + MeshNetwork rebuildWithWifiData({ required DevicesCodegenContext context, required Map wifiClientMap, required Map connectionDetailMap, @@ -172,28 +177,18 @@ class UspDevicesDataService { required String gatewayName, SystemInfoUIModel? systemInfo, }) { - final deviceModels = _buildDeviceUIModels( + return MeshNetworkBuilder.build( connectedDevices: context._connectedDevices, wifiClientMap: wifiClientMap, connectionDetailMap: connectionDetailMap, meshTopology: meshTopology, gatewayName: gatewayName, + systemInfo: systemInfo, ); - - final nodeModels = systemInfo != null - ? _buildNodeUIModels( - meshTopology: meshTopology, - deviceModels: deviceModels, - systemInfo: systemInfo, - ) - : []; - - return (deviceModels: deviceModels, nodeModels: nodeModels); } - /// Rebuilds device + node UI models after mesh topology arrives. - ({List deviceModels, List nodeModels}) - rebuildWithMesh({ + /// Rebuilds MeshNetwork after mesh topology arrives. + MeshNetwork rebuildWithMesh({ required DevicesCodegenContext context, required Map wifiClientMap, required Map connectionDetailMap, @@ -201,7 +196,6 @@ class UspDevicesDataService { required String gatewayName, SystemInfoUIModel? systemInfo, }) { - // Same logic as rebuildWithWifiData — both rebuild from context + enrichment. return rebuildWithWifiData( context: context, wifiClientMap: wifiClientMap, @@ -225,323 +219,4 @@ class UspDevicesDataService { } return map; } - - /// Normalizes hostname for grouping: lowercase + strip mDNS suffix. - /// - /// mDNS suffixes (e.g., "._tcp.local", "._device-info._tcp.local") are - /// stripped to ensure devices advertising via mDNS group correctly with - /// those using plain hostnames. - /// - /// Examples: - /// - "MacBook-Pro" → "macbook-pro" - /// - "MacBook._tcp.local" → "macbook" - /// - "iPhone._device-info._tcp.local" → "iphone" - String _normalizeHostname(String hostname) { - var normalized = hostname.trim().toLowerCase(); - if (normalized.isEmpty) return ''; - - // Strip mDNS suffix (matches device_classifier.dart behavior) - final mdnsSuffixIndex = normalized.indexOf('._'); - if (mdnsSuffixIndex > 0) { - normalized = normalized.substring(0, mdnsSuffixIndex); - } - return normalized; - } - - List _buildDeviceUIModels({ - required ConnectedDevices connectedDevices, - required Map wifiClientMap, - required Map connectionDetailMap, - required MeshTopologyInfo meshTopology, - required String gatewayName, - }) { - // Step 1: Build all DeviceUIModels (ungrouped) - final allDevices = connectedDevices.items - .where((d) => d.interface_.isNotEmpty || d.isActive) - .map((d) => _toDeviceUIModel( - d, wifiClientMap, connectionDetailMap, meshTopology, gatewayName)) - .toList(); - - // Step 2: Group by hostname (empty hostname devices stay ungrouped) - // Also exclude mesh nodes (master/slave) from grouping - final grouped = >{}; - final ungrouped = []; - - for (final device in allDevices) { - // Mesh nodes should not be grouped - if (device.isMeshNode) { - ungrouped.add(device); - continue; - } - - final hostname = _normalizeHostname(device.hostName); - if (hostname.isEmpty) { - ungrouped.add(device); - } else { - grouped.putIfAbsent(hostname, () => []).add(device); - } - } - - // Step 3: Merge devices with same hostname - final result = []; - - for (final devices in grouped.values) { - if (devices.length == 1) { - result.add(devices.first); - } else { - result.add(_mergeDevicesByHostname(devices)); - } - } - - result.addAll(ungrouped); - return result; - } - - /// Merges multiple DeviceUIModels with the same hostname into one. - /// - /// Primary interface selection priority: active > WiFi > Ethernet. - /// Additional interfaces are stored in [DeviceUIModel.additionalInterfaces]. - DeviceUIModel _mergeDevicesByHostname(List devices) { - // Sort to select primary interface: active first, then WiFi over Ethernet - final sorted = List.from(devices) - ..sort((a, b) { - // Active interfaces first - if (a.isActive != b.isActive) return a.isActive ? -1 : 1; - // WiFi preferred (typically has more enrichment data like signal) - if (a.isWifi != b.isWifi) return a.isWifi ? -1 : 1; - return 0; - }); - - final primary = sorted.first; - final additional = sorted - .skip(1) - .map((d) => DeviceInterfaceInfo( - mac: d.mac, - ip: d.ip, - isWifi: d.isWifi, - isActive: d.isActive, - layer1Interface: d.layer1Interface, - band: d.band, - ssidName: d.ssidName, - signalStrength: d.signalStrength, - )) - .toList(); - - logger.d('[USP][Devices]: Merged ${devices.length} interfaces for ' - 'hostname="${primary.hostName}" — primary=${primary.mac} (${primary.isWifi ? "WiFi" : "Ethernet"}), ' - 'additional=${additional.map((i) => "${i.mac} (${i.isWifi ? "WiFi" : "Ethernet"})").join(", ")}'); - - return primary.copyWith(additionalInterfaces: additional); - } - - List _buildNodeUIModels({ - required MeshTopologyInfo meshTopology, - required List deviceModels, - required SystemInfoUIModel systemInfo, - }) { - // Extract mesh nodes from Hosts (deviceRole = master/slave). - // This is available immediately without waiting for DataElements fetch. - final meshDevices = deviceModels.meshNodes; - - // Client devices only (excluding mesh nodes). - final clientDevices = deviceModels.clientDevices; - - // If no mesh devices found in Hosts, create gateway-only node. - if (meshDevices.isEmpty) { - return [ - NodeUIModel( - deviceId: 'gateway', - model: systemInfo.modelName, - manufacturer: systemInfo.manufacturer, - serialNumber: systemInfo.serialNumber, - softwareVersion: systemInfo.softwareVersion, - isMaster: true, - connectedDeviceCount: clientDevices.where((d) => d.isActive).length, - ), - ]; - } - - // Build nodes from Hosts deviceRole, enrich with DataElements if available. - final nodes = []; - - // Find master first. - final master = meshDevices.masterNode ?? meshDevices.first; - - // Master node — use systemInfo for details (Hosts doesn't have model/firmware). - final masterMeshInfo = - meshTopology.nodes.isNotEmpty ? meshTopology.nodes.first : null; - final masterConnectedCount = clientDevices - .where((d) => - d.isActive && - (d.parentNodeId == null || - d.parentNodeId!.toUpperCase() == master.mac.toUpperCase() || - (masterMeshInfo != null && - d.parentNodeId!.toUpperCase() == - masterMeshInfo.deviceId.toUpperCase()))) - .length; - - nodes.add(NodeUIModel( - deviceId: master.mac, - friendlyName: master.friendlyName, - hostName: master.hostName, - model: masterMeshInfo?.model ?? systemInfo.modelName, - manufacturer: masterMeshInfo?.manufacturer ?? systemInfo.manufacturer, - serialNumber: masterMeshInfo?.serialNumber ?? systemInfo.serialNumber, - softwareVersion: - masterMeshInfo?.softwareVersion ?? systemInfo.softwareVersion, - isMaster: true, - connectedDeviceCount: masterConnectedCount, - ipAddress: master.ip.isNotEmpty ? master.ip : null, - ipv6Addresses: master.ipv6Addresses, - )); - - // Slave nodes. - for (final slave in meshDevices.slaveNodes) { - final slaveMeshInfo = _findMatchingMeshNode(slave, meshTopology.nodes); - logger.d('[USP][Topology]: Slave ${slave.mac} matched to meshInfo: ' - '${slaveMeshInfo != null ? "yes (signalStrength=${slaveMeshInfo.backhaulSignalStrength})" : "no"}, ' - 'meshTopology.nodes.length=${meshTopology.nodes.length}'); - - final slaveConnectedCount = clientDevices - .where((d) => - d.isActive && - d.parentNodeId != null && - (d.parentNodeId!.toUpperCase() == slave.mac.toUpperCase() || - (slaveMeshInfo != null && - d.parentNodeId!.toUpperCase() == - slaveMeshInfo.deviceId.toUpperCase()))) - .length; - - nodes.add(NodeUIModel( - deviceId: slave.mac, - dataElementsId: slaveMeshInfo?.deviceId, - friendlyName: slave.friendlyName, - hostName: slave.hostName, - model: slaveMeshInfo?.model ?? slave.modelName ?? '', - manufacturer: slaveMeshInfo?.manufacturer ?? slave.manufacturer ?? '', - serialNumber: slaveMeshInfo?.serialNumber ?? '', - softwareVersion: slaveMeshInfo?.softwareVersion ?? '', - isMaster: false, - connectedDeviceCount: slaveConnectedCount, - ipAddress: slave.ip.isNotEmpty ? slave.ip : null, - ipv6Addresses: slave.ipv6Addresses, - backhaulMediaType: slaveMeshInfo?.backhaulMediaType ?? '', - backhaulPhyRate: slaveMeshInfo?.backhaulPhyRate ?? 0, - backhaulSignalStrength: slaveMeshInfo?.backhaulSignalStrength, - backhaulUplinkRate: slaveMeshInfo?.backhaulUplinkRate, - backhaulLinkType: slaveMeshInfo?.backhaulLinkType, - backhaulDownlinkRate: slaveMeshInfo?.backhaulDownlinkRate, - backhaulParentDeviceId: slaveMeshInfo?.backhaulParentDeviceId, - backhaulParentBssid: slaveMeshInfo?.backhaulParentBssid, - lastContactTime: slaveMeshInfo?.lastContactTime, - )); - } - - return nodes; - } - - DeviceUIModel _toDeviceUIModel( - ConnectedDevice device, - Map wifiClientMap, - Map connectionDetailMap, - MeshTopologyInfo meshTopology, - String gatewayName, - ) { - final mac = device.macAddress.trim().toUpperCase(); - // Determine WiFi via Layer1Interface or InterfaceType (fallback for empty Layer1Interface). - final interfaceType = device.interfaceType?.toLowerCase() ?? ''; - final isWifi = device.interface_.toLowerCase().contains('wifi') || - interfaceType.contains('wi-fi') || - interfaceType.contains('wifi'); - final wifiClient = wifiClientMap[mac]; - final detail = connectionDetailMap[mac]; - - String? parentNodeId; - String? parentNodeName; - if (meshTopology.isEmpty) { - if (device.isActive) parentNodeName = gatewayName; - } else { - parentNodeId = meshTopology.clientToNodeMap[mac]; - if (parentNodeId != null) { - final isGateway = meshTopology.nodes.isNotEmpty && - meshTopology.nodes.first.deviceId == parentNodeId; - if (isGateway) { - parentNodeName = gatewayName; - } else { - final matchingNode = meshTopology.nodes - .where((n) => n.deviceId == parentNodeId) - .firstOrNull; - parentNodeName = matchingNode?.model.isNotEmpty == true - ? matchingNode!.model - : parentNodeId; - } - } else { - parentNodeName = gatewayName; - } - } - - return DeviceUIModel( - mac: mac, - ip: device.ipAddress, - hostName: device.hostName, - isActive: device.isActive, - isWifi: isWifi, - layer1Interface: device.interface_, - ipv6Addresses: device.ipv6Addresses - .map((e) => e.address) - .where((a) => a.isNotEmpty) - .toList(), - // Prefer Hosts data, fallback to WiFi STA table data. - signalStrength: - isWifi ? (device.signalStrength ?? wifiClient?.signalStrength) : null, - downlinkRate: isWifi - ? (device.lastDataDownlinkRate ?? wifiClient?.lastDataDownlinkRate) - : null, - uplinkRate: isWifi - ? (device.lastDataUplinkRate ?? wifiClient?.lastDataUplinkRate) - : null, - band: detail?.band, - ssidName: detail?.ssidName, - parentNodeId: parentNodeId, - parentNodeName: parentNodeName, - deviceRole: device.deviceRole, - interfaceType: device.interfaceType, - friendlyName: device.friendlyName, - manufacturer: device.manufacturer, - hostsDeviceId: device.deviceId, - modelName: device.modelName, - operatingSystem: device.operatingSystem, - ); - } - - // --------------------------------------------------------------------------- - // Mesh Node Matching - // --------------------------------------------------------------------------- - - /// Finds a matching [NodeUIModel] for a slave device from Hosts. - /// - /// Strategy: Extract embedded MAC from Hosts DeviceID (UUID format) and match - /// against DataElements node ID. - /// - /// Hosts DeviceID format: "0217B8A4-1082-4532-8345-80691ABB4694" - /// Last 12 chars (no hyphens) = MAC: "80691ABB4694" → matches "80:69:1A:BB:46:94" - /// - /// Future alternatives if this approach proves unreliable: - /// - Match via BSSID: DataElements Radio.*.BSS.*.BSSID = Hosts PhysAddress - /// - Match via hostName suffix: "Linksys03027" → SerialNumber ending "03027" - NodeUIModel? _findMatchingMeshNode( - DeviceUIModel slave, - List meshNodes, - ) { - final hostsDeviceId = - slave.hostsDeviceId?.toUpperCase().replaceAll('-', '') ?? ''; - if (hostsDeviceId.length < 12) return null; - - final embeddedMac = hostsDeviceId.substring(hostsDeviceId.length - 12); - - return meshNodes.where((n) { - final nodeIdNormalized = n.deviceId.toUpperCase().replaceAll(':', ''); - return nodeIdNormalized == embeddedMac; - }).firstOrNull; - } } diff --git a/lib/page/devices/views/components/device_icon_with_badge.dart b/lib/page/devices/views/components/device_icon_with_badge.dart index 12171606c..2463caacd 100644 --- a/lib/page/devices/views/components/device_icon_with_badge.dart +++ b/lib/page/devices/views/components/device_icon_with_badge.dart @@ -1,8 +1,11 @@ import 'package:flutter/material.dart'; +import 'package:privacy_gui/core/utils/oui_lookup.dart'; -/// A device icon with an optional badge overlay in the bottom-right corner. +/// A device icon with optional badge overlays. /// -/// Used to indicate multi-interface devices (WiFi + Ethernet) in device lists. +/// Supports two badge positions: +/// - Bottom-right: multi-interface indicator (WiFi + Ethernet) +/// - Bottom-left: private MAC indicator (randomized address) class DeviceIconWithBadge extends StatelessWidget { final IconData icon; final double size; @@ -11,6 +14,7 @@ class DeviceIconWithBadge extends StatelessWidget { final IconData badgeIcon; final Color? badgeColor; final Color? badgeBackgroundColor; + final bool showPrivateMacBadge; const DeviceIconWithBadge({ super.key, @@ -21,15 +25,17 @@ class DeviceIconWithBadge extends StatelessWidget { this.badgeIcon = Icons.hub, this.badgeColor, this.badgeBackgroundColor, + this.showPrivateMacBadge = false, }); - /// Creates a device icon with multi-interface badge. + /// Creates a device icon with multi-interface and/or private MAC badges. factory DeviceIconWithBadge.multiInterface({ Key? key, required IconData icon, double size = 20, Color? iconColor, required bool hasMultipleInterfaces, + bool isPrivateMac = false, Color? badgeColor, Color? badgeBackgroundColor, }) { @@ -42,6 +48,31 @@ class DeviceIconWithBadge extends StatelessWidget { badgeIcon: Icons.hub, badgeColor: badgeColor, badgeBackgroundColor: badgeBackgroundColor, + showPrivateMacBadge: isPrivateMac, + ); + } + + /// Creates a device icon from MAC address, auto-detecting private MAC. + factory DeviceIconWithBadge.fromMac({ + Key? key, + required IconData icon, + required String mac, + double size = 20, + Color? iconColor, + bool hasMultipleInterfaces = false, + Color? badgeColor, + Color? badgeBackgroundColor, + }) { + return DeviceIconWithBadge( + key: key, + icon: icon, + size: size, + iconColor: iconColor, + showBadge: hasMultipleInterfaces, + badgeIcon: Icons.hub, + badgeColor: badgeColor, + badgeBackgroundColor: badgeBackgroundColor, + showPrivateMacBadge: OuiLookup.isRandomizedMac(mac), ); } @@ -52,11 +83,11 @@ class DeviceIconWithBadge extends StatelessWidget { final effectiveBadgeColor = badgeColor ?? scheme.primary; final effectiveBadgeBg = badgeBackgroundColor ?? scheme.surface; - if (!showBadge) { + if (!showBadge && !showPrivateMacBadge) { return Icon(icon, size: size, color: effectiveIconColor); } - final badgeSize = size * 0.5; + final badgeSize = size * 0.6; return SizedBox( width: size + badgeSize * 0.4, @@ -69,29 +100,67 @@ class DeviceIconWithBadge extends StatelessWidget { top: 0, child: Icon(icon, size: size, color: effectiveIconColor), ), - Positioned( - right: 0, - bottom: 0, - child: Container( - width: badgeSize, - height: badgeSize, - decoration: BoxDecoration( - color: effectiveBadgeBg, - shape: BoxShape.circle, - border: Border.all( - color: effectiveBadgeBg, - width: 1.5, - ), + // Multi-interface badge (bottom-right) + if (showBadge) + Positioned( + right: 0, + bottom: 0, + child: _BadgeCircle( + size: badgeSize, + icon: badgeIcon, + iconColor: effectiveBadgeColor, + backgroundColor: effectiveBadgeBg, ), - child: Icon( - badgeIcon, - size: badgeSize * 0.7, - color: effectiveBadgeColor, + ), + // Private MAC badge (bottom-left) + if (showPrivateMacBadge) + Positioned( + left: 0, + bottom: 0, + child: _BadgeCircle( + size: badgeSize, + icon: Icons.shuffle, + iconColor: scheme.tertiary, + backgroundColor: effectiveBadgeBg, ), ), - ), ], ), ); } } + +class _BadgeCircle extends StatelessWidget { + final double size; + final IconData icon; + final Color iconColor; + final Color backgroundColor; + + const _BadgeCircle({ + required this.size, + required this.icon, + required this.iconColor, + required this.backgroundColor, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: backgroundColor, + shape: BoxShape.circle, + border: Border.all( + color: backgroundColor, + width: 1.5, + ), + ), + child: Icon( + icon, + size: size * 0.7, + color: iconColor, + ), + ); + } +} diff --git a/lib/page/devices/views/components/usp_device_filter_panel.dart b/lib/page/devices/views/components/usp_device_filter_panel.dart index 4a2c3a0c1..547d71f0b 100644 --- a/lib/page/devices/views/components/usp_device_filter_panel.dart +++ b/lib/page/devices/views/components/usp_device_filter_panel.dart @@ -2,36 +2,17 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:privacy_gui/localization/localization_hook.dart'; import 'package:privacy_gui/page/_shared/components/layout_blocks.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; +import 'package:privacy_gui/page/_shared/utils/device_classifier.dart'; import 'package:privacy_gui/page/devices/providers/device_filter_provider.dart'; import 'package:privacy_gui/page/devices/providers/device_filter_state.dart'; +import 'package:privacy_gui/page/devices/views/components/usp_signal_strength_indicator.dart'; import 'package:privacy_gui/page/_shared/components/wifi_ui.dart'; -import 'package:ui_kit_library/ui_kit.dart'; +import 'package:ui_kit_library/ui_kit.dart' hide ConnectionType; -/// Sentinel value used in String-typed dropdowns to represent "All". Avoids -/// passing `null` as the value, which `AppDropdown` renders at 50% opacity -/// (its placeholder style). The `onChanged` handlers convert this back to -/// `null` before updating filter state. -const _kAllSentinel = '__ALL__'; - -String _connectionLabel(BuildContext context, DeviceConnectionFilter v) { - switch (v) { - case DeviceConnectionFilter.all: - return loc(context).all; - case DeviceConnectionFilter.wifi: - return loc(context).wifi; - case DeviceConnectionFilter.ethernet: - return loc(context).ethernet; - } -} - -/// Signal labels reuse `NodeSignalLevelExt.resolveLabel` so the strings are -/// localized and consistent with the node/topology pages. `all` and `unknown` -/// have no matching NodeSignalLevel and fall back to literal strings. -String _signalLabel(BuildContext context, DeviceSignalFilter v) { - if (v == DeviceSignalFilter.all) return loc(context).all; - if (v == DeviceSignalFilter.unknown) return '--'; - final level = nodeLevelOf(v); - return level?.resolveLabel(context) ?? loc(context).all; +String _signalLabel(BuildContext context, DeviceSignalLevel level) { + final nodeLevel = nodeLevelOf(level); + return nodeLevel?.resolveLabel(context) ?? ''; } String _statusLabel(BuildContext context, DeviceStatusFilter v) { @@ -45,23 +26,6 @@ String _statusLabel(BuildContext context, DeviceStatusFilter v) { } } -List _signalOptions(DeviceFilterOptions options) { - return [ - DeviceSignalFilter.all, - DeviceSignalFilter.excellent, - DeviceSignalFilter.good, - DeviceSignalFilter.fair, - DeviceSignalFilter.poor, - if (options.hasUnknownSignalDevices) DeviceSignalFilter.unknown, - ]; -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Status segmented control — shared by desktop and mobile/tablet layouts -// ═══════════════════════════════════════════════════════════════════════════ - -/// Status segmented control for switching between All/Online/Offline views. -/// Used above the device list in all layouts. class UspDeviceStatusSegmented extends ConsumerWidget { const UspDeviceStatusSegmented({super.key}); @@ -86,12 +50,6 @@ class UspDeviceStatusSegmented extends ConsumerWidget { } } -// ═══════════════════════════════════════════════════════════════════════════ -// Desktop filter panel -// ═══════════════════════════════════════════════════════════════════════════ - -/// Desktop filter panel — vertical sidebar sized to 3 grid columns. -/// Status filter is NOT included here — it lives above the device list. class UspDeviceFilterPanel extends ConsumerWidget { const UspDeviceFilterPanel({super.key}); @@ -101,11 +59,8 @@ class UspDeviceFilterPanel extends ConsumerWidget { final options = ref.watch(deviceFilterOptionsProvider); final isOffline = filter.status == DeviceStatusFilter.offline; - final isEthernet = filter.connection == DeviceConnectionFilter.ethernet; - final hasMultipleNodes = options.nodes.length > 1; + final isEthernetOnly = filter.isEthernetOnly; - // When Offline is chosen, other filters are meaningless — offline devices - // have no live SSID / band / RSSI / node. Show explanatory note only. if (isOffline) { return SizedBox( width: context.colWidth(3), @@ -148,7 +103,7 @@ class UspDeviceFilterPanel extends ConsumerWidget { ), AppGap.md(), - // ── CONNECTION ──────────────────────────────────────────────── + // CONNECTION LayoutBlock( padding: const EdgeInsets.all(AppSpacing.md), child: Column( @@ -156,111 +111,187 @@ class UspDeviceFilterPanel extends ConsumerWidget { children: [ _SectionHeader(title: loc(context).connection), AppGap.sm(), - _DropdownRow( + _ChipGroupRow( + label: loc(context).type, + chips: [ + ChipItem(label: loc(context).wifi), + ChipItem(label: loc(context).ethernet), + ], + selectedIndices: _connectionToIndices(filter.connections), + onSelectionChanged: (indices) => ref + .read(deviceFilterConfigProvider.notifier) + .setConnections(_indicesToConnections(indices)), + ), + if (options.nodes.isNotEmpty) ...[ + AppGap.sm(), + _ChipGroupRow( + label: loc(context).node, + chips: options.nodes + .map((n) => ChipItem(label: n.model)) + .toList(), + selectedIndices: _nodeIdsToIndices( + filter.nodeIds, + options.nodes.map((n) => n.deviceId).toList(), + ), + onSelectionChanged: (indices) => ref + .read(deviceFilterConfigProvider.notifier) + .setNodeIds(_indicesToNodeIds( + indices, + options.nodes.map((n) => n.deviceId).toList(), + )), + ), + ], + ], + ), + ), + + // DEVICE + AppGap.sm(), + LayoutBlock( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionHeader(title: loc(context).devices), + AppGap.sm(), + _ChipGroupRow( label: loc(context).type, - value: filter.connection, - items: DeviceConnectionFilter.values, - labelOf: (v) => _connectionLabel(context, v), - onChanged: (v) => ref + chips: DeviceCategory.values + .map((c) => ChipItem( + label: '', + iconWidget: Icon(c.icon, size: 16), + )) + .toList(), + selectedIndices: _categoriesToIndices( + filter.deviceCategories, + DeviceCategory.values, + ), + onSelectionChanged: (indices) => ref .read(deviceFilterConfigProvider.notifier) - .setConnection(v ?? DeviceConnectionFilter.all), + .setDeviceCategories(_indicesToCategories( + indices, + DeviceCategory.values, + )), ), AppGap.sm(), - _DropdownRow( - label: loc(context).signal, - value: filter.signal, - items: _signalOptions(options), - labelOf: (v) => _signalLabel(context, v), - disabled: isEthernet, - disabledTooltip: - loc(context).notApplicableForEthernetDevices, - onChanged: (v) => ref + _ChipGroupRow( + label: 'MAC', + chips: [ + ChipItem(label: loc(context).privateMac), + ChipItem(label: loc(context).publicMac), + ], + selectedIndices: _privateMacToIndices(filter.privateMac), + onSelectionChanged: (indices) => ref .read(deviceFilterConfigProvider.notifier) - .setSignal(v ?? DeviceSignalFilter.all), + .setPrivateMac(_indicesToPrivateMac(indices)), ), ], ), ), - // ── WI-FI ───────────────────────────────────────────────────── - // Hide the whole WiFi section when there is no data to offer; - // grey it out when the user has scoped to Ethernet. - if (options.ssids.isNotEmpty || options.bands.isNotEmpty) ...[ - AppGap.sm(), - LayoutBlock( - padding: const EdgeInsets.all(AppSpacing.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _SectionHeader(title: loc(context).wifi), - AppGap.sm(), - if (options.ssids.isNotEmpty) - _DropdownRow( - label: loc(context).ssid, - value: filter.ssidName ?? _kAllSentinel, - items: [_kAllSentinel, ...options.ssids], - labelOf: (v) => - v == _kAllSentinel ? loc(context).all : v, - disabled: isEthernet, - disabledTooltip: - loc(context).notApplicableForEthernetDevices, - onChanged: (v) => ref - .read(deviceFilterConfigProvider.notifier) - .setSsidName(v == _kAllSentinel ? null : v), + // WI-FI + AppGap.sm(), + LayoutBlock( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionHeader(title: loc(context).wifi), + AppGap.sm(), + _ChipGroupRow( + label: loc(context).signal, + chips: [ + ChipItem( + label: '', + iconWidget: UspSignalStrengthIndicator.fixed( + level: 3, + color: + NodeSignalLevel.excellent.resolveColor(context) ?? + Colors.green, + ), ), - if (options.ssids.isNotEmpty && options.bands.isNotEmpty) - AppGap.sm(), - if (options.bands.isNotEmpty) - _DropdownRow( - label: loc(context).band, - value: filter.band ?? _kAllSentinel, - items: [_kAllSentinel, ...options.bands], - labelOf: (v) => - v == _kAllSentinel ? loc(context).all : v, - disabled: isEthernet, - disabledTooltip: - loc(context).notApplicableForEthernetDevices, - onChanged: (v) => ref - .read(deviceFilterConfigProvider.notifier) - .setBand(v == _kAllSentinel ? null : v), + ChipItem( + label: '', + iconWidget: UspSignalStrengthIndicator.fixed( + level: 2, + color: NodeSignalLevel.good.resolveColor(context) ?? + Colors.green, + ), ), + ChipItem( + label: '', + iconWidget: UspSignalStrengthIndicator.fixed( + level: 1, + color: NodeSignalLevel.fair.resolveColor(context) ?? + Colors.orange, + ), + ), + ChipItem( + label: '', + iconWidget: UspSignalStrengthIndicator.fixed( + level: 0, + color: NodeSignalLevel.poor.resolveColor(context) ?? + Colors.red, + ), + ), + if (options.hasUnknownSignalDevices) + ChipItem(label: '--'), + ], + selectedIndices: _signalToIndices( + filter.signals, + filter.includeUnknownSignal, + options.hasUnknownSignalDevices, + ), + disabled: isEthernetOnly, + disabledTooltip: + loc(context).notApplicableForEthernetDevices, + onSelectionChanged: (indices) { + final (signals, includeUnknown) = _indicesToSignals( + indices, + options.hasUnknownSignalDevices, + ); + final notifier = + ref.read(deviceFilterConfigProvider.notifier); + notifier.setSignals(signals); + notifier.setIncludeUnknownSignal(includeUnknown); + }, + ), + if (options.ssids.isNotEmpty) ...[ + AppGap.sm(), + _ChipGroupRow( + label: loc(context).ssid, + chips: + options.ssids.map((s) => ChipItem(label: s)).toList(), + selectedIndices: + _stringsToIndices(filter.ssidNames, options.ssids), + disabled: isEthernetOnly, + disabledTooltip: + loc(context).notApplicableForEthernetDevices, + onSelectionChanged: (indices) => ref + .read(deviceFilterConfigProvider.notifier) + .setSsidNames( + _indicesToStrings(indices, options.ssids)), + ), ], - ), - ), - ], - - // ── LOCATION ────────────────────────────────────────────────── - if (hasMultipleNodes) ...[ - AppGap.sm(), - LayoutBlock( - padding: const EdgeInsets.all(AppSpacing.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _SectionHeader(title: loc(context).location), + if (options.bands.isNotEmpty) ...[ AppGap.sm(), - _DropdownRow( - label: loc(context).connectedVia, - value: filter.nodeId ?? _kAllSentinel, - items: [ - _kAllSentinel, - ...options.nodes.map((n) => n.deviceId), - ], - labelOf: (id) { - if (id == _kAllSentinel) return loc(context).all; - final node = options.nodes - .where((n) => n.deviceId == id) - .firstOrNull; - return node?.model ?? id; - }, - onChanged: (v) => ref + _ChipGroupRow( + label: loc(context).band, + chips: + options.bands.map((b) => ChipItem(label: b)).toList(), + selectedIndices: + _stringsToIndices(filter.bands, options.bands), + disabled: isEthernetOnly, + disabledTooltip: + loc(context).notApplicableForEthernetDevices, + onSelectionChanged: (indices) => ref .read(deviceFilterConfigProvider.notifier) - .setNodeId(v == _kAllSentinel ? null : v), + .setBands(_indicesToStrings(indices, options.bands)), ), ], - ), + ], ), - ], + ), ], ), ), @@ -268,6 +299,116 @@ class UspDeviceFilterPanel extends ConsumerWidget { } } +Set _connectionToIndices(Set types) { + final indices = {}; + if (types.contains(ConnectionType.wifi)) indices.add(0); + if (types.contains(ConnectionType.wired)) indices.add(1); + return indices; +} + +Set _indicesToConnections(Set indices) { + final types = {}; + if (indices.contains(0)) types.add(ConnectionType.wifi); + if (indices.contains(1)) types.add(ConnectionType.wired); + return types; +} + +Set _signalToIndices( + Set signals, + bool includeUnknown, + bool hasUnknownOption, +) { + final indices = {}; + if (signals.contains(DeviceSignalLevel.excellent)) indices.add(0); + if (signals.contains(DeviceSignalLevel.good)) indices.add(1); + if (signals.contains(DeviceSignalLevel.fair)) indices.add(2); + if (signals.contains(DeviceSignalLevel.poor)) indices.add(3); + if (hasUnknownOption && includeUnknown) indices.add(4); + return indices; +} + +(Set, bool) _indicesToSignals( + Set indices, + bool hasUnknownOption, +) { + final signals = {}; + if (indices.contains(0)) signals.add(DeviceSignalLevel.excellent); + if (indices.contains(1)) signals.add(DeviceSignalLevel.good); + if (indices.contains(2)) signals.add(DeviceSignalLevel.fair); + if (indices.contains(3)) signals.add(DeviceSignalLevel.poor); + final includeUnknown = hasUnknownOption && indices.contains(4); + return (signals, includeUnknown); +} + +Set _stringsToIndices(Set selected, List options) { + final indices = {}; + for (var i = 0; i < options.length; i++) { + if (selected.contains(options[i])) indices.add(i); + } + return indices; +} + +Set _indicesToStrings(Set indices, List options) { + return indices + .where((i) => i < options.length) + .map((i) => options[i]) + .toSet(); +} + +Set _nodeIdsToIndices(Set nodeIds, List allNodeIds) { + final indices = {}; + for (var i = 0; i < allNodeIds.length; i++) { + if (nodeIds.contains(allNodeIds[i])) indices.add(i); + } + return indices; +} + +Set _indicesToNodeIds(Set indices, List allNodeIds) { + return indices + .where((i) => i < allNodeIds.length) + .map((i) => allNodeIds[i]) + .toSet(); +} + +Set _categoriesToIndices( + Set selected, + List options, +) { + final indices = {}; + for (var i = 0; i < options.length; i++) { + if (selected.contains(options[i])) indices.add(i); + } + return indices; +} + +Set _indicesToCategories( + Set indices, + List options, +) { + return indices + .where((i) => i < options.length) + .map((i) => options[i]) + .toSet(); +} + +Set _privateMacToIndices(PrivateMacFilter filter) { + return switch (filter) { + PrivateMacFilter.all => const {}, + PrivateMacFilter.privateOnly => const {0}, + PrivateMacFilter.publicOnly => const {1}, + }; +} + +PrivateMacFilter _indicesToPrivateMac(Set indices) { + if (indices.contains(0) && !indices.contains(1)) { + return PrivateMacFilter.privateOnly; + } + if (indices.contains(1) && !indices.contains(0)) { + return PrivateMacFilter.publicOnly; + } + return PrivateMacFilter.all; +} + class _FilterHeader extends StatelessWidget { const _FilterHeader({required this.activeCount, required this.onClear}); @@ -328,85 +469,71 @@ class _InfoNote extends StatelessWidget { } } -class _DropdownRow extends StatelessWidget { - const _DropdownRow({ +class _ChipGroupRow extends StatelessWidget { + const _ChipGroupRow({ required this.label, - required this.value, - required this.items, - required this.labelOf, - required this.onChanged, + required this.chips, + required this.selectedIndices, + required this.onSelectionChanged, this.disabled = false, this.disabledTooltip, }); final String label; - final T value; - final List items; - final String Function(T) labelOf; - final ValueChanged onChanged; + final List chips; + final Set selectedIndices; + final ValueChanged> onSelectionChanged; final bool disabled; final String? disabledTooltip; @override Widget build(BuildContext context) { - // AppDropdown's `_displayString(null)` returns the hint, so we pass the - // "All …" label as hint to get a readable placeholder when value is null. - final displayWhenNull = value == null ? labelOf(value) : null; - - Widget dropdown = AppDropdown( - items: items, - value: value, - itemAsString: labelOf, - hint: displayWhenNull, - onChanged: onChanged, - ); - - // `AppDropdown` treats `onChanged: null` as disabled visually, but its - // internal `AppInteractionSensor.onTap` still opens the menu. Wrap with - // IgnorePointer to actually block interaction when disabled. - if (disabled) { - dropdown = IgnorePointer(child: dropdown); - } - - final child = Row( - crossAxisAlignment: CrossAxisAlignment.center, + return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - SizedBox( - width: 80, - child: AppText.labelMedium( - label, - color: disabled - ? Theme.of(context).disabledColor - : Theme.of(context).colorScheme.onSurface, - ), + Row( + children: [ + AppText.labelMedium( + label, + color: disabled + ? Theme.of(context).disabledColor + : Theme.of(context).colorScheme.onSurface, + ), + if (disabled && disabledTooltip != null) ...[ + AppGap.xs(), + AppTooltip( + message: disabledTooltip!, + child: Icon( + Icons.info_outline, + size: 16, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ], ), - AppGap.sm(), - Expanded(child: dropdown), - if (disabled && disabledTooltip != null) ...[ - AppGap.xs(), - AppTooltip( - message: disabledTooltip!, - child: Icon( - Icons.info_outline, - size: 16, - color: Theme.of(context).colorScheme.onSurfaceVariant, + AppGap.xs(), + IgnorePointer( + ignoring: disabled, + child: Opacity( + opacity: disabled ? 0.5 : 1.0, + child: AppChipGroup( + chips: chips, + selectedIndices: selectedIndices, + selectionMode: ChipSelectionMode.multiple, + onSelectionChanged: onSelectionChanged, + wrap: true, + spacing: AppSpacing.xs, + size: ChipSize.compact, ), ), - ], + ), ], ); - - return disabled ? Opacity(opacity: 0.5, child: child) : child; } } -// ═══════════════════════════════════════════════════════════════════════════ // Mobile/Tablet chip bar -// ═══════════════════════════════════════════════════════════════════════════ - -/// Mobile/tablet filter bar — horizontal chip row for quick filtering. -/// Status filter is NOT included here — it lives above the device list. -/// Dependency rules: Offline hides all chips, Ethernet greys out WiFi-scoped chips. class UspDeviceFilterChipBar extends ConsumerWidget { const UspDeviceFilterChipBar({super.key}); @@ -417,101 +544,110 @@ class UspDeviceFilterChipBar extends ConsumerWidget { final notifier = ref.read(deviceFilterConfigProvider.notifier); final isOffline = filter.status == DeviceStatusFilter.offline; - final isEthernet = filter.connection == DeviceConnectionFilter.ethernet; + final isEthernetOnly = filter.isEthernetOnly; - // When Offline, no additional filters are meaningful if (isOffline) { return const SizedBox.shrink(); } final chips = [ - _Chip( - label: filter.connection == DeviceConnectionFilter.all + _FilterChip( + label: filter.connections.isEmpty ? loc(context).connection - : _connectionLabel(context, filter.connection), - isActive: filter.connection != DeviceConnectionFilter.all, - onTap: () => _showPicker( - context, + : filter.connections.length == 1 + ? (filter.connections.first == ConnectionType.wifi + ? loc(context).wifi + : loc(context).ethernet) + : '${loc(context).connection} (${filter.connections.length})', + isActive: filter.connections.isNotEmpty, + onTap: () => _showMultiSelectPicker( + context: context, title: loc(context).connection, - items: DeviceConnectionFilter.values, - selected: filter.connection, - labelOf: (v) => _connectionLabel(context, v), - onSelected: notifier.setConnection, + items: ConnectionType.values, + selected: filter.connections, + labelOf: (v) => v == ConnectionType.wifi + ? loc(context).wifi + : loc(context).ethernet, + onChanged: notifier.setConnections, ), ), - _Chip( - label: filter.signal == DeviceSignalFilter.all - ? loc(context).signal - : _signalLabel(context, filter.signal), - isActive: filter.signal != DeviceSignalFilter.all, - disabled: isEthernet, - onTap: () => _showPicker( - context, - title: loc(context).signal, - items: _signalOptions(options), - selected: filter.signal, - labelOf: (v) => _signalLabel(context, v), - onSelected: notifier.setSignal, + _FilterChip( + label: _buildSignalChipLabel(context, filter, options), + isActive: filter.signals.isNotEmpty || filter.includeUnknownSignal, + disabled: isEthernetOnly, + onTap: () => _showSignalPicker( + context: context, + filter: filter, + options: options, + notifier: notifier, ), ), if (options.ssids.isNotEmpty) - _Chip( - label: filter.ssidName ?? loc(context).ssid, - isActive: filter.ssidName != null, - disabled: isEthernet, - onTap: () => _showPicker( - context, + _FilterChip( + label: filter.ssidNames.isEmpty + ? loc(context).ssid + : filter.ssidNames.length == 1 + ? filter.ssidNames.first + : '${loc(context).ssid} (${filter.ssidNames.length})', + isActive: filter.ssidNames.isNotEmpty, + disabled: isEthernetOnly, + onTap: () => _showMultiSelectPicker( + context: context, title: loc(context).ssid, - items: [null, ...options.ssids], - selected: filter.ssidName, - labelOf: (v) => v ?? loc(context).all, - onSelected: notifier.setSsidName, + items: options.ssids, + selected: filter.ssidNames, + labelOf: (v) => v, + onChanged: notifier.setSsidNames, ), ), if (options.bands.isNotEmpty) - _Chip( - label: filter.band ?? loc(context).band, - isActive: filter.band != null, - disabled: isEthernet, - onTap: () => _showPicker( - context, + _FilterChip( + label: filter.bands.isEmpty + ? loc(context).band + : filter.bands.length == 1 + ? filter.bands.first + : '${loc(context).band} (${filter.bands.length})', + isActive: filter.bands.isNotEmpty, + disabled: isEthernetOnly, + onTap: () => _showMultiSelectPicker( + context: context, title: loc(context).band, - items: [null, ...options.bands], - selected: filter.band, - labelOf: (v) => v ?? loc(context).all, - onSelected: notifier.setBand, + items: options.bands, + selected: filter.bands, + labelOf: (v) => v, + onChanged: notifier.setBands, ), ), if (options.nodes.length > 1) - _Chip( - label: filter.nodeId != null - ? (options.nodes - .where((n) => n.deviceId == filter.nodeId) - .firstOrNull - ?.model ?? - filter.nodeId!) - : loc(context).node, - isActive: filter.nodeId != null, - onTap: () => _showPicker( - context, + _FilterChip( + label: filter.nodeIds.isEmpty + ? loc(context).node + : filter.nodeIds.length == 1 + ? (options.nodes + .where((n) => n.deviceId == filter.nodeIds.first) + .firstOrNull + ?.model ?? + filter.nodeIds.first) + : '${loc(context).node} (${filter.nodeIds.length})', + isActive: filter.nodeIds.isNotEmpty, + onTap: () => _showMultiSelectPicker( + context: context, title: loc(context).node, - items: [null, ...options.nodes.map((n) => n.deviceId)], - selected: filter.nodeId, - labelOf: (id) { - if (id == null) return loc(context).all; - return options.nodes - .where((n) => n.deviceId == id) - .firstOrNull - ?.model ?? - id; - }, - onSelected: notifier.setNodeId, + items: options.nodes.map((n) => n.deviceId).toList(), + selected: filter.nodeIds, + labelOf: (id) => + options.nodes + .where((n) => n.deviceId == id) + .firstOrNull + ?.model ?? + id, + onChanged: notifier.setNodeIds, ), ), ]; if (filter.activeCountExcludingStatus > 0) { - chips.add(_Chip( + chips.add(_FilterChip( label: loc(context).clear, isActive: false, onTap: notifier.clearAll, @@ -531,42 +667,141 @@ class UspDeviceFilterChipBar extends ConsumerWidget { ); } - void _showPicker( - BuildContext context, { + String _buildSignalChipLabel( + BuildContext context, + DeviceFilterConfig filter, + DeviceFilterOptions options, + ) { + final count = filter.signals.length + (filter.includeUnknownSignal ? 1 : 0); + if (count == 0) return loc(context).signal; + if (count == 1) { + if (filter.includeUnknownSignal) return '--'; + return _signalLabel(context, filter.signals.first); + } + return '${loc(context).signal} ($count)'; + } + + void _showMultiSelectPicker({ + required BuildContext context, required String title, required List items, - required T selected, + required Set selected, required String Function(T) labelOf, - required void Function(T) onSelected, + required void Function(Set) onChanged, }) { + Set tempSelected = Set.from(selected); + showModalBottomSheet( context: context, - builder: (ctx) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.all(AppSpacing.lg), - child: AppText.titleMedium(title), - ), - ...items.map((item) => ListTile( - title: Text(labelOf(item)), - selected: item == selected, - onTap: () { - onSelected(item); - Navigator.pop(ctx); + builder: (ctx) => StatefulBuilder( + builder: (context, setSheetState) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + AppText.titleMedium(title), + AppButton.text( + label: loc(context).done, + onTap: () { + onChanged(tempSelected); + Navigator.pop(ctx); + }, + ), + ], + ), + ), + ...items.map((item) => CheckboxListTile( + title: Text(labelOf(item)), + value: tempSelected.contains(item), + onChanged: (checked) { + setSheetState(() { + if (checked == true) { + tempSelected.add(item); + } else { + tempSelected.remove(item); + } + }); + }, + )), + const SizedBox(height: AppSpacing.lg), + ], + ), + ), + ), + ); + } + + void _showSignalPicker({ + required BuildContext context, + required DeviceFilterConfig filter, + required DeviceFilterOptions options, + required DeviceFilterNotifier notifier, + }) { + var tempSignals = Set.from(filter.signals); + var tempIncludeUnknown = filter.includeUnknownSignal; + + showModalBottomSheet( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (context, setSheetState) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + AppText.titleMedium(loc(context).signal), + AppButton.text( + label: loc(context).done, + onTap: () { + notifier.setSignals(tempSignals); + notifier.setIncludeUnknownSignal(tempIncludeUnknown); + Navigator.pop(ctx); + }, + ), + ], + ), + ), + ...DeviceSignalLevel.values.map((level) => CheckboxListTile( + title: Text(_signalLabel(context, level)), + value: tempSignals.contains(level), + onChanged: (checked) { + setSheetState(() { + if (checked == true) { + tempSignals.add(level); + } else { + tempSignals.remove(level); + } + }); + }, + )), + if (options.hasUnknownSignalDevices) + CheckboxListTile( + title: const Text('--'), + value: tempIncludeUnknown, + onChanged: (checked) { + setSheetState(() { + tempIncludeUnknown = checked ?? false; + }); }, - )), - const SizedBox(height: AppSpacing.lg), - ], + ), + const SizedBox(height: AppSpacing.lg), + ], + ), ), ), ); } } -class _Chip extends StatelessWidget { - const _Chip({ +class _FilterChip extends StatelessWidget { + const _FilterChip({ required this.label, required this.isActive, required this.onTap, diff --git a/lib/page/devices/views/components/usp_device_list_tile.dart b/lib/page/devices/views/components/usp_device_list_tile.dart index ec2ebce54..eac76e390 100644 --- a/lib/page/devices/views/components/usp_device_list_tile.dart +++ b/lib/page/devices/views/components/usp_device_list_tile.dart @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:privacy_gui/core/utils/oui_lookup.dart'; import 'package:privacy_gui/localization/localization_hook.dart'; import 'package:privacy_gui/page/_shared/utils/device_classifier.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; import 'package:privacy_gui/page/_shared/components/usp_status_dot.dart'; import 'package:privacy_gui/page/devices/views/components/device_icon_with_badge.dart'; import 'package:privacy_gui/page/devices/views/components/usp_signal_strength_indicator.dart'; @@ -34,7 +35,7 @@ enum DeviceListTileVariant { /// - Set [variant] to [DeviceListTileVariant.flat] for embedded lists (e.g. /// inside a card) to avoid double card borders. class UspDeviceListTile extends StatelessWidget { - final DeviceUIModel device; + final ClientDevice device; final VoidCallback? onTap; final DeviceListTileVariant variant; @@ -66,6 +67,7 @@ class UspDeviceListTile extends StatelessWidget { size: 20, iconColor: scheme.onSurface, hasMultipleInterfaces: device.hasMultipleInterfaces, + isPrivateMac: OuiLookup.isRandomizedMac(device.mac), ), AppGap.sm(), Expanded( diff --git a/lib/page/devices/views/components/usp_signal_strength_indicator.dart b/lib/page/devices/views/components/usp_signal_strength_indicator.dart index a83a8562d..157c9a58c 100644 --- a/lib/page/devices/views/components/usp_signal_strength_indicator.dart +++ b/lib/page/devices/views/components/usp_signal_strength_indicator.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:privacy_gui/core/utils/wifi.dart'; import 'package:privacy_gui/page/_shared/components/wifi_ui.dart'; import 'package:ui_kit_library/ui_kit.dart'; @@ -24,6 +23,34 @@ class UspSignalStrengthIndicator extends StatelessWidget { this.showLabel = true, }); + /// Compact variant for filter chips (no label, smaller bars). + const UspSignalStrengthIndicator.compact({ + super.key, + required this.rssi, + this.barWidth = 3, + this.barSpacing = 1.5, + this.maxBarHeight = 14, + }) : showLabel = false; + + /// Static variant with fixed level (0-3) and color for filter chip icons. + factory UspSignalStrengthIndicator.fixed({ + Key? key, + required int level, + required Color color, + double barWidth = 3, + double barSpacing = 1.5, + double maxBarHeight = 14, + }) { + return _FixedSignalBars( + key: key, + level: level, + color: color, + barWidth: barWidth, + barSpacing: barSpacing, + maxBarHeight: maxBarHeight, + ); + } + /// Render level: 0 (poor) to 3 (excellent). Maps the project-wide /// [NodeSignalLevel] onto the four bars. int get _level { @@ -76,3 +103,42 @@ class UspSignalStrengthIndicator extends StatelessWidget { ); } } + +/// Fixed-level signal bars for filter chip icons. +class _FixedSignalBars extends UspSignalStrengthIndicator { + final int level; + final Color color; + + const _FixedSignalBars({ + super.key, + required this.level, + required this.color, + super.barWidth = 3, + super.barSpacing = 1.5, + super.maxBarHeight = 14, + }) : super(rssi: 0, showLabel: false); + + @override + Widget build(BuildContext context) { + final inactiveColor = + Theme.of(context).colorScheme.outlineVariant.withValues(alpha: 0.4); + + return Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + for (int i = 0; i < 4; i++) ...[ + Container( + width: barWidth, + height: maxBarHeight * (0.25 + 0.25 * i), + decoration: BoxDecoration( + color: i <= level ? color : inactiveColor, + borderRadius: BorderRadius.circular(1), + ), + ), + if (i < 3) SizedBox(width: barSpacing), + ], + ], + ); + } +} diff --git a/lib/page/devices/views/usp_device_detail_view.dart b/lib/page/devices/views/usp_device_detail_view.dart index 28e2a5103..f2600c5d3 100644 --- a/lib/page/devices/views/usp_device_detail_view.dart +++ b/lib/page/devices/views/usp_device_detail_view.dart @@ -4,10 +4,11 @@ import 'package:privacy_gui/route/navigation_extensions.dart'; import 'package:privacy_gui/components/ui_kit_page_view.dart'; import 'package:privacy_gui/localization/localization_hook.dart'; import 'package:privacy_gui/page/_shared/utils/device_classifier.dart'; +import 'package:privacy_gui/core/utils/ipv6_address.dart'; import 'package:privacy_gui/core/utils/oui_lookup.dart'; import 'package:privacy_gui/route/constants.dart'; -import 'package:privacy_gui/page/_shared/extensions/device_ui_extensions.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart' + hide ConnectionType; import 'package:privacy_gui/page/_shared/components/usp_mutation_helper.dart'; import 'package:privacy_gui/page/_shared/components/detail_widgets.dart'; import 'package:privacy_gui/page/_shared/components/layout_blocks.dart'; @@ -82,7 +83,7 @@ class _UspDeviceDetailViewState extends ConsumerState { // =========================================================================== Widget _buildMobileLayout(BuildContext context, WidgetRef ref, - DeviceUIModel device, DeviceDetailState detail, bool isLoading) { + ClientDevice device, DeviceDetailState detail, bool isLoading) { return Column( children: [ _buildDeviceIdentityCard(context, device), @@ -99,7 +100,7 @@ class _UspDeviceDetailViewState extends ConsumerState { } Widget _buildDesktopLayout(BuildContext context, WidgetRef ref, - DeviceUIModel device, DeviceDetailState detail, bool isLoading) { + ClientDevice device, DeviceDetailState detail, bool isLoading) { return Column( children: [ DetailGridRow( @@ -121,7 +122,7 @@ class _UspDeviceDetailViewState extends ConsumerState { // Device Identity Card // =========================================================================== - Widget _buildDeviceIdentityCard(BuildContext context, DeviceUIModel device) { + Widget _buildDeviceIdentityCard(BuildContext context, ClientDevice device) { final classification = DeviceClassifier.classifyWithConfidence( hostname: device.hostName, mac: device.mac, @@ -214,8 +215,7 @@ class _UspDeviceDetailViewState extends ConsumerState { // Connection Status Card // =========================================================================== - Widget _buildConnectionStatusCard( - BuildContext context, DeviceUIModel device) { + Widget _buildConnectionStatusCard(BuildContext context, ClientDevice device) { final hasMultipleInterfaces = device.hasMultipleInterfaces; return AppCard( @@ -264,7 +264,7 @@ class _UspDeviceDetailViewState extends ConsumerState { // =========================================================================== Widget _buildMultiInterfaceSection( - BuildContext context, DeviceUIModel device) { + BuildContext context, ClientDevice device) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -423,7 +423,7 @@ class _UspDeviceDetailViewState extends ConsumerState { // WiFi Details Card // =========================================================================== - Widget _buildWifiDetailsCard(BuildContext context, DeviceUIModel device) { + Widget _buildWifiDetailsCard(BuildContext context, ClientDevice device) { final hasSignalData = device.signalStrength != null; final hasSpeedData = device.downlinkRate != null || device.uplinkRate != null; @@ -467,39 +467,9 @@ class _UspDeviceDetailViewState extends ConsumerState { _buildSpeedRow(context, device), AppGap.sm(), ], - if (hasBandSsid || device.interfaceType?.isNotEmpty == true) + if (hasBandSsid) Row( children: [ - if (device.interfaceType?.isNotEmpty == true) ...[ - Expanded( - child: LayoutBlock( - padding: const EdgeInsets.all(AppSpacing.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(Icons.settings_input_antenna, - size: 16, - color: Theme.of(context) - .colorScheme - .onSurfaceVariant), - AppGap.xs(), - AppText.labelSmall(loc(context).labelInterface, - color: Theme.of(context) - .colorScheme - .onSurfaceVariant), - ], - ), - AppGap.xs(), - AppText.bodyMedium(device.interfaceType!), - ], - ), - ), - ), - if (device.band != null || device.ssidName != null) - AppGap.sm(), - ], if (device.band != null) ...[ Expanded( child: LayoutBlock( @@ -563,7 +533,7 @@ class _UspDeviceDetailViewState extends ConsumerState { ); } - Widget _buildSignalLayoutBlock(BuildContext context, DeviceUIModel device) { + Widget _buildSignalLayoutBlock(BuildContext context, ClientDevice device) { return LayoutBlock( padding: const EdgeInsets.all(AppSpacing.md), child: Row( @@ -595,7 +565,7 @@ class _UspDeviceDetailViewState extends ConsumerState { ); } - Widget _buildSpeedRow(BuildContext context, DeviceUIModel device) { + Widget _buildSpeedRow(BuildContext context, ClientDevice device) { final colorScheme = Theme.of(context).colorScheme; return Row( children: [ @@ -627,8 +597,7 @@ class _UspDeviceDetailViewState extends ConsumerState { // Network Addresses Card (for non-active or wired devices on desktop) // =========================================================================== - Widget _buildNetworkAddressesCard( - BuildContext context, DeviceUIModel device) { + Widget _buildNetworkAddressesCard(BuildContext context, ClientDevice device) { final isWifi = device.isWifi; return AppCard( padding: const EdgeInsets.all(AppSpacing.md), @@ -667,7 +636,7 @@ class _UspDeviceDetailViewState extends ConsumerState { // =========================================================================== Widget _buildDhcpCard(BuildContext context, WidgetRef ref, - DeviceUIModel device, DeviceDetailState detail, bool isLoading) { + ClientDevice device, DeviceDetailState detail, bool isLoading) { final colorScheme = Theme.of(context).colorScheme; final hasValidIpv4 = NetworkUtils.isValidIpAddress(device.ip); @@ -752,14 +721,26 @@ class _UspDeviceDetailViewState extends ConsumerState { // IPv6 Section // =========================================================================== - Widget _buildIpv6Section(BuildContext context, List addresses) { + Widget _buildIpv6Section(BuildContext context, List rawAddresses) { final colorScheme = Theme.of(context).colorScheme; + // Surface globally routable addresses first so the collapsed view (which + // shows only the first entry) never leads with a link-local address, while + // still enumerating every address when expanded. See #1128/#1129. + final addresses = preferGlobalIpv6First(rawAddresses); final displayCount = _ipv6Expanded ? addresses.length : 1; + // When the representative (first) address is link-local, the leading icon + // itself signals the scope (public_off + tooltip) instead of a duplicate + // trailing badge. See #1128/#1129. + final leadingIsLinkLocal = + addresses.isNotEmpty && isLinkLocalIpv6(addresses.first); return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(Icons.language, size: 16, color: colorScheme.onSurfaceVariant), + if (leadingIsLinkLocal) + const Ipv6ScopeBadge(size: 16) + else + Icon(Icons.language, size: 16, color: colorScheme.onSurfaceVariant), AppGap.sm(), Expanded( child: Column( @@ -825,7 +806,7 @@ class _UspDeviceDetailViewState extends ConsumerState { // =========================================================================== Future _reserveIp( - BuildContext context, WidgetRef ref, DeviceUIModel device) async { + BuildContext context, WidgetRef ref, ClientDevice device) async { await performUspMutation( context, ref, diff --git a/lib/page/devices/views/usp_device_list_view.dart b/lib/page/devices/views/usp_device_list_view.dart index 8c7cf9199..53bd42875 100644 --- a/lib/page/devices/views/usp_device_list_view.dart +++ b/lib/page/devices/views/usp_device_list_view.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:privacy_gui/components/ui_kit_page_view.dart'; +import 'package:privacy_gui/components/views/service_error_view.dart'; +import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/localization/localization_hook.dart'; import 'package:privacy_gui/route/constants.dart'; import 'package:privacy_gui/page/_shared/components/detail_widgets.dart'; @@ -50,8 +52,11 @@ class _UspDeviceListViewState extends ConsumerState { child: (childContext, constraints) { return asyncDevices.when( loading: () => const Center(child: AppLoader()), - error: (e, _) => - Center(child: AppText.bodyMedium('${loc(context).error}: $e')), + error: (e, _) => ServiceErrorView( + error: e is ServiceError ? e : null, + title: loc(context).unableToGatherDeviceInfo, + onRetry: () => ref.invalidate(devicesDataProvider), + ), data: (state) { return AppResponsiveLayout( mobile: (_) => @@ -116,7 +121,7 @@ class _UspDeviceListViewState extends ConsumerState { child: UspDeviceListTile( device: device, variant: DeviceListTileVariant.flatLast, - onTap: () => context.goNamed( + onTap: () => context.pushNamed( RouteNamed.uspDeviceDetail, queryParameters: {'mac': device.mac}, ), diff --git a/lib/page/dhcp/providers/dhcp_client_filter_provider.dart b/lib/page/dhcp/providers/dhcp_client_filter_provider.dart new file mode 100644 index 000000000..5927b608b --- /dev/null +++ b/lib/page/dhcp/providers/dhcp_client_filter_provider.dart @@ -0,0 +1,9 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// Filter options for DHCP client list display. +enum DhcpClientFilter { all, onlineOnly } + +/// Persists the selected DHCP client filter across page navigation. +final dhcpClientFilterProvider = StateProvider( + (ref) => DhcpClientFilter.all, +); diff --git a/lib/page/dhcp/providers/usp_dhcp_reservations_notifier.dart b/lib/page/dhcp/providers/usp_dhcp_reservations_notifier.dart index 087b02099..b95939067 100644 --- a/lib/page/dhcp/providers/usp_dhcp_reservations_notifier.dart +++ b/lib/page/dhcp/providers/usp_dhcp_reservations_notifier.dart @@ -10,8 +10,18 @@ import 'package:privacy_gui/page/dhcp/models/dhcp_reservation_list.dart'; import 'package:privacy_gui/page/dhcp/models/dhcp_reservations_feature_state.dart'; import 'package:privacy_gui/page/dhcp/models/dhcp_reservations_status.dart'; import 'package:privacy_gui/page/dhcp/services/usp_dhcp_service.dart'; +import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; import 'package:privacy_gui/page/local_network/providers/dhcp_data_provider.dart'; +/// Pure-data snapshot of a client device offered as an autocomplete suggestion +/// when adding or editing a reservation. The view maps this to UI options. +typedef ReservationDeviceOption = ({ + String name, + String mac, + String ip, + bool isActive, +}); + // --------------------------------------------------------------------------- // Providers // --------------------------------------------------------------------------- @@ -164,6 +174,25 @@ class UspDhcpReservationsNotifier ref.invalidate(dhcpDataProvider); } + // --------------------------------------------------------------------------- + // Device suggestions (data source for reservation add/edit autocomplete) + // --------------------------------------------------------------------------- + + /// Client devices offered as autocomplete suggestions when adding or editing + /// a reservation. Returns pure data — the view maps these to UI options. + List deviceOptions() { + final devices = + ref.read(devicesDataProvider).valueOrNull?.clientDevices ?? []; + return devices + .map((d) => ( + name: d.displayName, + mac: d.mac, + ip: d.ip, + isActive: d.isActive, + )) + .toList(); + } + // --------------------------------------------------------------------------- // Local Mutations (synchronous — no network calls) // --------------------------------------------------------------------------- diff --git a/lib/page/dhcp/views/components/usp_dhcp_active_leases_card.dart b/lib/page/dhcp/views/components/usp_dhcp_active_leases_card.dart index 2de1cd7bc..1adb0ef5c 100644 --- a/lib/page/dhcp/views/components/usp_dhcp_active_leases_card.dart +++ b/lib/page/dhcp/views/components/usp_dhcp_active_leases_card.dart @@ -1,23 +1,33 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:privacy_gui/localization/localization_hook.dart'; import 'package:privacy_gui/page/_shared/components/detail_widgets.dart'; import 'package:privacy_gui/page/_shared/components/layout_blocks.dart'; import 'package:privacy_gui/page/_shared/models/dhcp_client_ui_model.dart'; +import 'package:privacy_gui/page/dhcp/providers/dhcp_client_filter_provider.dart'; import 'package:ui_kit_library/ui_kit.dart'; -/// Read-only card displaying active DHCP client leases. -class UspDhcpActiveLeasesCard extends StatelessWidget { +/// Read-only card displaying DHCP client leases with filter chips. +class UspDhcpActiveLeasesCard extends ConsumerWidget { final List clients; const UspDhcpActiveLeasesCard({super.key, required this.clients}); @override - Widget build(BuildContext context) { - final activeCount = clients.where((c) => c.active).length; - final sorted = List.from(clients) + Widget build(BuildContext context, WidgetRef ref) { + final filter = ref.watch(dhcpClientFilterProvider); + final onlineCount = clients.where((c) => c.isOnline == true).length; + + final filtered = filter == DhcpClientFilter.onlineOnly + ? clients.where((c) => c.isOnline == true).toList() + : clients; + + final sorted = List.from(filtered) ..sort((a, b) { - // Active first, then by displayName - if (a.active != b.active) return a.active ? -1 : 1; + // Online first, then by displayName + final aOnline = a.isOnline == true; + final bOnline = b.isOnline == true; + if (aOnline != bOnline) return aOnline ? -1 : 1; return a.displayName .toLowerCase() .compareTo(b.displayName.toLowerCase()); @@ -32,9 +42,11 @@ class UspDhcpActiveLeasesCard extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ AppText.titleSmall(loc(context).activeLeases), - AppText.labelLarge('$activeCount / ${clients.length}'), + AppText.labelLarge('$onlineCount / ${clients.length}'), ], ), + AppGap.sm(), + _buildFilterChips(context, ref, filter), AppGap.md(), if (sorted.isEmpty) DetailEmptyBlock( @@ -48,6 +60,37 @@ class UspDhcpActiveLeasesCard extends StatelessWidget { ); } + Widget _buildFilterChips( + BuildContext context, + WidgetRef ref, + DhcpClientFilter filter, + ) { + const filters = DhcpClientFilter.values; + return AppChipGroup( + chips: filters + .map((f) => ChipItem(label: _filterLabel(context, f))) + .toList(), + selectedIndices: {filters.indexOf(filter)}, + selectionMode: ChipSelectionMode.single, + onSelectionChanged: (indices) { + if (indices.isNotEmpty) { + ref.read(dhcpClientFilterProvider.notifier).state = + filters[indices.first]; + } + }, + wrap: false, + ); + } + + String _filterLabel(BuildContext context, DhcpClientFilter filter) { + switch (filter) { + case DhcpClientFilter.all: + return loc(context).all; + case DhcpClientFilter.onlineOnly: + return loc(context).online; + } + } + Widget _buildClientRow(BuildContext context, DhcpClientUIModel client) { final colorScheme = Theme.of(context).colorScheme; final lease = client.leaseTimeFormatted; @@ -60,7 +103,8 @@ class UspDhcpActiveLeasesCard extends StatelessWidget { Icon( Icons.circle, size: 8, - color: client.active ? Colors.green : colorScheme.outline, + color: + client.isOnline == true ? Colors.green : colorScheme.outline, ), AppGap.sm(), Expanded( @@ -68,14 +112,11 @@ class UspDhcpActiveLeasesCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ AppText.bodyMedium(client.displayName), - AppText.bodySmall( - [ - if (client.hostName.isNotEmpty) client.mac, - if (client.leaseExpiryFormatted.isNotEmpty) - loc(context).leaseExpiry(client.leaseExpiryFormatted), - ].join(' · '), - color: colorScheme.onSurfaceVariant, - ), + if (client.hostName.isNotEmpty) + AppText.bodySmall( + client.mac, + color: colorScheme.onSurfaceVariant, + ), ], ), ), @@ -86,15 +127,24 @@ class UspDhcpActiveLeasesCard extends StatelessWidget { color: colorScheme.onSurfaceVariant, ), ), - if (lease.isNotEmpty) - SizedBox( - width: context.colWidth(1), - child: AppText.bodySmall( - lease, - color: colorScheme.onSurfaceVariant, - textAlign: TextAlign.end, - ), + SizedBox( + width: context.colWidth(2), + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (lease.isNotEmpty) + AppText.bodySmall( + lease, + color: colorScheme.onSurfaceVariant, + ), + if (client.leaseExpiryFormatted.isNotEmpty) + AppText.bodySmall( + client.leaseExpiryFormatted, + color: colorScheme.onSurfaceVariant, + ), + ], ), + ), ], ), ), diff --git a/lib/page/dhcp/views/components/usp_dhcp_reservations_detail_card.dart b/lib/page/dhcp/views/components/usp_dhcp_reservations_detail_card.dart index 9ba9329d8..838dded0f 100644 --- a/lib/page/dhcp/views/components/usp_dhcp_reservations_detail_card.dart +++ b/lib/page/dhcp/views/components/usp_dhcp_reservations_detail_card.dart @@ -6,7 +6,6 @@ import 'package:privacy_gui/localization/localization_hook.dart'; import 'package:privacy_gui/page/_shared/components/detail_widgets.dart'; import 'package:privacy_gui/page/_shared/components/layout_blocks.dart'; import 'package:privacy_gui/page/_shared/models/dhcp_reservation_ui_model.dart'; -import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; import 'package:privacy_gui/page/dhcp/providers/usp_dhcp_reservations_notifier.dart'; import 'package:privacy_gui/page/dhcp/views/dialogs/dhcp_reservation_edit_dialog.dart'; import 'package:ui_kit_library/ui_kit.dart'; @@ -105,14 +104,16 @@ class UspDhcpReservationsDetailCard extends ConsumerWidget { ); } + /// Maps device data from the notifier to autocomplete UI options for the + /// MAC and IP fields. ({List mac, List ip}) _buildDeviceOptions(WidgetRef ref) { final devices = - ref.read(devicesDataProvider).valueOrNull?.deviceModels ?? []; + ref.read(uspDhcpReservationsProvider.notifier).deviceOptions(); final macOptions = devices .where((d) => d.mac.isNotEmpty) .map((d) => AppAutoCompleteOption( - label: d.displayName, + label: d.name, value: d.mac, subtitle: d.ip, isActive: d.isActive, @@ -121,7 +122,7 @@ class UspDhcpReservationsDetailCard extends ConsumerWidget { final ipOptions = devices .where((d) => d.ip.isNotEmpty) .map((d) => AppAutoCompleteOption( - label: d.displayName, + label: d.name, value: d.ip, subtitle: d.mac, isActive: d.isActive, @@ -137,6 +138,7 @@ class UspDhcpReservationsDetailCard extends ConsumerWidget { builder: (_) => DhcpReservationEditDialog( macDeviceOptions: options.mac, ipDeviceOptions: options.ip, + existingReservations: reservations, ), ); if (result == null || !context.mounted) return; @@ -161,6 +163,7 @@ class UspDhcpReservationsDetailCard extends ConsumerWidget { reservation: reservation, macDeviceOptions: options.mac, ipDeviceOptions: options.ip, + existingReservations: reservations, ), ); if (result == null || !context.mounted) return; diff --git a/lib/page/dhcp/views/dialogs/dhcp_reservation_edit_dialog.dart b/lib/page/dhcp/views/dialogs/dhcp_reservation_edit_dialog.dart index f886877a9..1a1cb7ba5 100644 --- a/lib/page/dhcp/views/dialogs/dhcp_reservation_edit_dialog.dart +++ b/lib/page/dhcp/views/dialogs/dhcp_reservation_edit_dialog.dart @@ -14,11 +14,16 @@ class DhcpReservationEditDialog extends StatefulWidget { final List macDeviceOptions; final List ipDeviceOptions; + /// Existing reservations, used to reject duplicate MAC/IP addresses. + /// When editing, the reservation being edited is excluded from the check. + final List existingReservations; + const DhcpReservationEditDialog({ super.key, this.reservation, this.macDeviceOptions = const [], this.ipDeviceOptions = const [], + this.existingReservations = const [], }); @override @@ -33,6 +38,8 @@ class _DhcpReservationEditDialogState extends State { late TextEditingController _macController; late TextEditingController _ipController; + final _macFocusNode = FocusNode(); + final _ipFocusNode = FocusNode(); late bool _enabled; Map _errors = {}; @@ -49,15 +56,29 @@ class _DhcpReservationEditDialogState extends State { _macController = TextEditingController(text: r?.mac ?? ''); _ipController = TextEditingController(text: r?.ip ?? ''); _enabled = r?.enable ?? true; + _macFocusNode.addListener(_onMacFocusChange); + _ipFocusNode.addListener(_onIpFocusChange); } @override void dispose() { + _macFocusNode.removeListener(_onMacFocusChange); + _ipFocusNode.removeListener(_onIpFocusChange); + _macFocusNode.dispose(); + _ipFocusNode.dispose(); _macController.dispose(); _ipController.dispose(); super.dispose(); } + void _onMacFocusChange() { + if (!_macFocusNode.hasFocus) _validate(); + } + + void _onIpFocusChange() { + if (!_ipFocusNode.hasFocus) _validate(); + } + void _validate() { final errors = {}; final mac = _macController.text.trim(); @@ -75,6 +96,35 @@ class _DhcpReservationEditDialogState extends State { } } + // Reject duplicates against existing reservations (excluding the one being + // edited). Comparison is case-insensitive for MAC addresses. + // + // Exclude "self" by stable identity (instancePath) rather than Equatable + // value-equality: the DHCP page listens to SSE invalidations, so a non-key + // field (e.g. `enable`) on the edited reservation can drift in + // existingReservations while the dialog is open. Value-equality would then + // fail to match self and flag the user's own unchanged MAC/IP as a + // duplicate. instancePath is the stable device-side identity (null only for + // not-yet-saved local reservations, which cannot be edited). + final self = widget.reservation; + final others = widget.existingReservations.where((r) { + if (self == null) return true; + if (self.instancePath != null && r.instancePath != null) { + return r.instancePath != self.instancePath; + } + return !identical(r, self); + }); + if (mac.isNotEmpty && + errors['mac'] == null && + others.any((r) => r.mac.toLowerCase() == mac.toLowerCase())) { + errors['mac'] = 'duplicateMacAddress'; + } + if (ip.isNotEmpty && + errors['ip'] == null && + others.any((r) => r.ip == ip)) { + errors['ip'] = 'duplicateIpAddress'; + } + setState(() => _errors = errors); } @@ -84,6 +134,8 @@ class _DhcpReservationEditDialogState extends State { 'invalidMacAddressFormat' => loc(context).invalidMacAddressFormat, 'invalidIpv4Format' => loc(context).invalidIpv4Format, 'reservedIpNotAllowed' => loc(context).reservedIpNotAllowed, + 'duplicateMacAddress' => loc(context).duplicateMacAddress, + 'duplicateIpAddress' => loc(context).duplicateIpAddress, _ => key, }; } @@ -111,8 +163,8 @@ class _DhcpReservationEditDialogState extends State { }, child: AppTextField( controller: _macController, + focusNode: _macFocusNode, hintText: loc(context).macAddressHint, - onChanged: (_) => _validate(), errorText: _localizeError(_errors['mac']), ), ), @@ -131,8 +183,8 @@ class _DhcpReservationEditDialogState extends State { }, child: AppTextField( controller: _ipController, + focusNode: _ipFocusNode, hintText: loc(context).ipAddressHint, - onChanged: (_) => _validate(), errorText: _localizeError(_errors['ip']), ), ), @@ -163,6 +215,8 @@ class _DhcpReservationEditDialogState extends State { } void _submit() { + _validate(); + if (!_isFormValid) return; final mac = _macController.text.trim(); final ip = _ipController.text.trim(); context.pop((mac: mac, ip: ip, enable: _enabled)); diff --git a/lib/page/dhcp/views/usp_dhcp_detail_view.dart b/lib/page/dhcp/views/usp_dhcp_detail_view.dart index 41bcfd6b8..0ac2f7571 100644 --- a/lib/page/dhcp/views/usp_dhcp_detail_view.dart +++ b/lib/page/dhcp/views/usp_dhcp_detail_view.dart @@ -62,6 +62,7 @@ class UspDhcpDetailView extends ConsumerWidget { if (reservationStatus.error != null) { return ServiceErrorView( error: reservationStatus.error, + title: loc(context).failedToLoadSettings, onRetry: () { ref.invalidate(dhcpDataProvider); ref @@ -75,6 +76,7 @@ class UspDhcpDetailView extends ConsumerWidget { final asyncError = asyncDhcp.error; return ServiceErrorView( error: asyncError is ServiceError ? asyncError : null, + title: loc(context).failedToLoadSettings, onRetry: () { ref.invalidate(dhcpDataProvider); ref diff --git a/lib/page/dmz/models/dmz_ui_model.dart b/lib/page/dmz/models/dmz_ui_model.dart index e8084baa7..12b055659 100644 --- a/lib/page/dmz/models/dmz_ui_model.dart +++ b/lib/page/dmz/models/dmz_ui_model.dart @@ -1,4 +1,5 @@ import 'package:equatable/equatable.dart'; +import 'package:privacy_gui/framework/diagnostic_loggable.dart'; /// How the source IP restriction is configured. enum DmzSourceType { @@ -14,7 +15,7 @@ enum DmzSourceType { /// The router's TR-181 model is multi-instance (`Device.Firewall.DMZ.{i}.`) /// but DMZ is practically a single-entry feature: either 0 or 1 entry exists. /// This model abstracts that into a simple enable/disable + settings view. -class DmzUIModel extends Equatable { +class DmzUIModel extends Equatable with DiagnosticLoggable { /// Whether the DMZ entry exists and is enabled. final bool isEnabled; @@ -56,5 +57,13 @@ class DmzUIModel extends Equatable { } @override - List get props => [isEnabled, destIp, sourceType, sourcePrefix]; + String get diagnosticName => 'DmzUIModel'; + + @override + Map get namedProps => { + 'isEnabled': isEnabled, + 'destIp': destIp, + 'sourceType': sourceType.name, + 'sourcePrefix': sourcePrefix, + }; } diff --git a/lib/page/dmz/providers/usp_dmz_notifier.dart b/lib/page/dmz/providers/usp_dmz_notifier.dart index 00eb8f82c..55f22d1f1 100644 --- a/lib/page/dmz/providers/usp_dmz_notifier.dart +++ b/lib/page/dmz/providers/usp_dmz_notifier.dart @@ -120,15 +120,27 @@ class UspDmzNotifier extends AutoDisposeNotifier // UI Mutation (synchronous — no network call) // --------------------------------------------------------------------------- - /// Update a single DMZ setting and re-validate. + /// Update a single DMZ setting WITHOUT triggering validation. + /// + /// Use this for onChange handlers to avoid TextField unfocus on Web. + /// Call [validate] separately on unfocus. void updateSetting(DmzUIModel Function(DmzUIModel) updater) { final current = state.settings.current; final newModel = updater(current.model); - final errors = _svc.validateForm(newModel); state = state.copyWith( settings: state.settings.update( current.copyWith(model: newModel), ), + ); + } + + /// Trigger validation on current settings. + /// + /// Call this on TextField unfocus to avoid unfocus issues on Web. + void validate() { + final current = state.settings.current.model; + final errors = _svc.validateForm(current); + state = state.copyWith( status: state.status.copyWith(fieldErrors: errors), ); } diff --git a/lib/page/dmz/views/usp_dmz_view.dart b/lib/page/dmz/views/usp_dmz_view.dart index a99340091..b2fed972e 100644 --- a/lib/page/dmz/views/usp_dmz_view.dart +++ b/lib/page/dmz/views/usp_dmz_view.dart @@ -26,16 +26,33 @@ class UspDmzView extends ConsumerStatefulWidget { class _UspDmzViewState extends ConsumerState { late TextEditingController _destIpController; late TextEditingController _cidrController; + final _cidrFocus = FocusNode(); @override void initState() { super.initState(); _destIpController = TextEditingController(); _cidrController = TextEditingController(); + _cidrFocus.addListener(_onCidrFocusChange); + } + + void _onCidrFocusChange() { + if (!_cidrFocus.hasFocus) { + ref.read(uspDmzProvider.notifier).validate(); + } + } + + void _onDestIpFocusChanged(int? index, bool hasFocus) { + // Only validate when focus leaves the entire IPv4 field + if (index == null && !hasFocus) { + ref.read(uspDmzProvider.notifier).validate(); + } } @override void dispose() { + _cidrFocus.removeListener(_onCidrFocusChange); + _cidrFocus.dispose(); _destIpController.dispose(); _cidrController.dispose(); super.dispose(); @@ -76,6 +93,7 @@ class _UspDmzViewState extends ConsumerState { if (status.error != null) { return ServiceErrorView( error: status.error, + title: loc(context).failedToLoadSettings, onRetry: () => ref.read(uspDmzProvider.notifier).fetch(forceRemote: true), ); @@ -95,13 +113,19 @@ class _UspDmzViewState extends ConsumerState { WidgetRef ref, DmzFeatureState state, ) { - if (!state.isDirty) return null; + // Always return a config to keep widget tree stable. + // Returning null when !isDirty causes tree structure change, + // which triggers TextField unfocus on Flutter Web. return UiKitBottomBarConfig( positiveLabel: loc(context).save, - isPositiveEnabled: - !state.status.isSaving && state.status.fieldErrors.isEmpty, - onPositiveTap: () => _onSave(context, ref), - onNegativeTap: () => ref.read(uspDmzProvider.notifier).revert(), + isPositiveEnabled: state.isDirty && + !state.status.isSaving && + state.status.fieldErrors.isEmpty, + isNegativeEnabled: state.isDirty, + onPositiveTap: state.isDirty ? () => _onSave(context, ref) : null, + onNegativeTap: state.isDirty + ? () => ref.read(uspDmzProvider.notifier).revert() + : null, ); } @@ -205,6 +229,7 @@ class _UspDmzViewState extends ConsumerState { onChanged: (value) { notifier.updateSetting((m) => m.copyWith(destIp: value)); }, + onFocusChanged: _onDestIpFocusChanged, errorText: ref.watch(uspDmzProvider).status.fieldErrors['destIp'], ), ), @@ -247,6 +272,7 @@ class _UspDmzViewState extends ConsumerState { constraints: const BoxConstraints(maxWidth: 429), child: AppTextFormField( controller: _cidrController, + focusNode: _cidrFocus, hintText: 'e.g. 192.168.1.0/24', onChanged: (value) { notifier.updateSetting( diff --git a/lib/page/firewall/models/firewall_ui_model.dart b/lib/page/firewall/models/firewall_ui_model.dart index d3575dbd2..c8d1f1f69 100644 --- a/lib/page/firewall/models/firewall_ui_model.dart +++ b/lib/page/firewall/models/firewall_ui_model.dart @@ -1,11 +1,12 @@ import 'package:equatable/equatable.dart'; +import 'package:privacy_gui/framework/diagnostic_loggable.dart'; /// Presentation Layer Model for firewall settings. /// /// Maps to JNAP GetFirewallSettings / SetFirewallSettings fields. /// The underlying TR-181 data comes from Device.Firewall.Chain.1.Rule.{i} /// where each rule's Description identifies the feature. -class FirewallUIModel extends Equatable { +class FirewallUIModel extends Equatable with DiagnosticLoggable { /// IPv4 SPI (Stateful Packet Inspection) firewall enabled. final bool isIPv4FirewallEnabled; @@ -67,14 +68,17 @@ class FirewallUIModel extends Equatable { } @override - List get props => [ - isIPv4FirewallEnabled, - isIPv6FirewallEnabled, - blockIPSec, - blockPPTP, - blockL2TP, - blockAnonymousRequests, - blockMulticast, - blockIDENT, - ]; + String get diagnosticName => 'FirewallUIModel'; + + @override + Map get namedProps => { + 'isIPv4FirewallEnabled': isIPv4FirewallEnabled, + 'isIPv6FirewallEnabled': isIPv6FirewallEnabled, + 'blockIPSec': blockIPSec, + 'blockPPTP': blockPPTP, + 'blockL2TP': blockL2TP, + 'blockAnonymousRequests': blockAnonymousRequests, + 'blockMulticast': blockMulticast, + 'blockIDENT': blockIDENT, + }; } diff --git a/lib/page/firewall/providers/firewall_data_provider.dart b/lib/page/firewall/providers/firewall_data_provider.dart index b942fc266..f494ad143 100644 --- a/lib/page/firewall/providers/firewall_data_provider.dart +++ b/lib/page/firewall/providers/firewall_data_provider.dart @@ -2,8 +2,8 @@ import 'dart:async'; import 'package:equatable/equatable.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:privacy_gui/core/utils/logger.dart'; import 'package:privacy_gui/core/usp/providers/sse_invalidation_provider.dart'; +import 'package:privacy_gui/framework/diagnostic_loggable.dart'; import 'package:privacy_gui/page/dmz/models/dmz_ui_model.dart'; import 'package:privacy_gui/page/firewall/models/firewall_ui_model.dart'; import 'package:privacy_gui/page/firewall/services/usp_firewall_data_service.dart'; @@ -39,7 +39,7 @@ class DmzEntrySummary extends Equatable { // Data Model (Layer 1 — UIModel only) // --------------------------------------------------------------------------- -class FirewallData extends Equatable { +class FirewallData extends Equatable with DiagnosticLoggable { /// Pre-built firewall toggle model. final FirewallUIModel firewallModel; @@ -70,6 +70,21 @@ class FirewallData extends Equatable { dmzModel = const DmzUIModel.disabled(), dmzSummaries = const []; + @override + String get diagnosticName => 'FirewallData'; + + @override + Map get namedProps => { + 'firewallModel': firewallModel, + 'ruleCount': ruleSummaries.length, + 'dmzModel': dmzModel, + }; + + // Explicit props override for reliable equality comparison (includes + // ruleContext and the full rule/DMZ summary lists). namedProps is kept lean + // for diagnostic JSON output — deriving props from it would narrow equality + // to ruleSummaries.length and drop ruleContext/dmzSummaries, so a rule whose + // content changed without changing the count would not notify listeners. @override List get props => [firewallModel, ruleContext, ruleSummaries, dmzModel, dmzSummaries]; @@ -110,10 +125,6 @@ class FirewallDataNotifier extends AsyncNotifier { final svc = ref.read(uspFirewallDataServiceProvider); final result = await svc.fetch(); - logger.d('[USP][FirewallData]: Fetched — ' - 'rules: ${result.ruleSummaries.length}, ' - 'dmz: ${result.dmzSummaries.length}'); - return FirewallData( firewallModel: result.firewallModel, ruleContext: result.ruleContext, diff --git a/lib/page/firewall/views/usp_firewall_view.dart b/lib/page/firewall/views/usp_firewall_view.dart index 6da93ada5..1edf29587 100644 --- a/lib/page/firewall/views/usp_firewall_view.dart +++ b/lib/page/firewall/views/usp_firewall_view.dart @@ -43,6 +43,7 @@ class UspFirewallView extends ConsumerWidget { if (status.error != null) { return ServiceErrorView( error: status.error, + title: loc(context).failedToLoadSettings, onRetry: () => ref.read(uspFirewallProvider.notifier).fetch(forceRemote: true), ); diff --git a/lib/page/firmware_update/models/firmware_image_ui_model.dart b/lib/page/firmware_update/models/firmware_image_ui_model.dart index 8b9ff032e..36d3bf4aa 100644 --- a/lib/page/firmware_update/models/firmware_image_ui_model.dart +++ b/lib/page/firmware_update/models/firmware_image_ui_model.dart @@ -1,6 +1,7 @@ import 'package:equatable/equatable.dart'; +import 'package:privacy_gui/framework/diagnostic_loggable.dart'; -class FirmwareImageUIModel extends Equatable { +class FirmwareImageUIModel extends Equatable with DiagnosticLoggable { final int instance; final String instancePath; final String name; @@ -22,6 +23,16 @@ class FirmwareImageUIModel extends Equatable { bool get isActive => status == 'Active'; @override - List get props => - [instance, instancePath, name, version, status, available, isBootTarget]; + String get diagnosticName => 'FirmwareImageUIModel'; + + @override + Map get namedProps => { + 'instance': instance, + 'instancePath': instancePath, + 'name': name, + 'version': version, + 'status': status, + 'available': available, + 'isBootTarget': isBootTarget, + }; } diff --git a/lib/page/firmware_update/providers/firmware_banks_data_provider.dart b/lib/page/firmware_update/providers/firmware_banks_data_provider.dart index c5f55dc88..b1b37aefd 100644 --- a/lib/page/firmware_update/providers/firmware_banks_data_provider.dart +++ b/lib/page/firmware_update/providers/firmware_banks_data_provider.dart @@ -1,12 +1,13 @@ import 'package:equatable/equatable.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:privacy_gui/core/utils/logger.dart'; +import 'package:privacy_gui/framework/diagnostic_loggable.dart'; import 'package:privacy_gui/page/firmware_update/models/firmware_image_ui_model.dart'; import 'package:privacy_gui/page/firmware_update/services/firmware_banks_data_service.dart'; // ── Data Model ── -class FirmwareBanksData extends Equatable { +class FirmwareBanksData extends Equatable with DiagnosticLoggable { final List banks; const FirmwareBanksData({required this.banks}); @@ -20,7 +21,10 @@ class FirmwareBanksData extends Equatable { banks.where((b) => b.available && !b.isActive).firstOrNull; @override - List get props => [banks]; + String get diagnosticName => 'FirmwareBanksData'; + + @override + Map get namedProps => {'banks': banks}; } // ── Provider ── diff --git a/lib/page/firmware_update/providers/firmware_update_notifier.dart b/lib/page/firmware_update/providers/firmware_update_notifier.dart index d2a3e0e50..b944528df 100644 --- a/lib/page/firmware_update/providers/firmware_update_notifier.dart +++ b/lib/page/firmware_update/providers/firmware_update_notifier.dart @@ -9,7 +9,6 @@ import 'package:privacy_gui/core/utils/logger.dart'; import 'package:privacy_gui/page/admin/providers/system_info_data_provider.dart'; import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; import 'package:privacy_gui/page/firmware_update/models/firmware_image_ui_model.dart'; -import 'package:privacy_gui/page/topology/models/node_ui_model.dart'; import 'package:privacy_gui/page/firmware_update/models/firmware_ota_info.dart'; import 'package:privacy_gui/page/firmware_update/models/firmware_update_phase.dart'; import 'package:privacy_gui/page/firmware_update/models/firmware_update_state.dart'; @@ -89,15 +88,11 @@ class FirmwareUpdateNotifier extends AutoDisposeNotifier { /// Build OTA check parameters from device data providers. /// - /// Returns `null` if required data is unavailable (e.g., master node not found). + /// Returns `null` if required data is unavailable. Future buildOtaCheckParams() async { try { final devicesData = await ref.read(devicesDataProvider.future); - final masterNode = devicesData.nodeModels.master; - if (masterNode == null) { - logger.w('[FirmwareUpdate] Master node not found'); - return null; - } + final master = devicesData.master; final systemInfoData = await ref.read(systemInfoDataProvider.future); final hardwareVersion = @@ -107,9 +102,9 @@ class FirmwareUpdateNotifier extends AutoDisposeNotifier { final ipAddress = wanData.model.ipAddress; return FirmwareOtaCheckParams( - macAddress: _formatMacAddress(masterNode.deviceId), - installedVersion: masterNode.softwareVersion, - modelNumber: masterNode.model, + macAddress: _formatMacAddress(master.deviceId), + installedVersion: master.softwareVersion, + modelNumber: master.model, hardwareVersion: hardwareVersion, ipAddress: ipAddress, ); diff --git a/lib/page/instant_privacy/models/instant_privacy_device_ui_model.dart b/lib/page/instant_privacy/models/instant_privacy_device_ui_model.dart index 513e2579a..67e3cba7a 100644 --- a/lib/page/instant_privacy/models/instant_privacy_device_ui_model.dart +++ b/lib/page/instant_privacy/models/instant_privacy_device_ui_model.dart @@ -12,11 +12,19 @@ class InstantPrivacyDeviceUIModel extends Equatable { /// Display name: hostname if available, otherwise falls back to [mac]. final String displayName; + /// Whether [mac] is a locally-administered (private/randomized) address. + /// + /// Devices using a private WiFi address rotate their MAC, so whitelisting + /// the current value risks locking the device out after it rotates. The UI + /// surfaces a warning for these before enabling the feature. + final bool isPrivateMac; + const InstantPrivacyDeviceUIModel({ required this.mac, required this.displayName, + this.isPrivateMac = false, }); @override - List get props => [mac, displayName]; + List get props => [mac, displayName, isPrivateMac]; } diff --git a/lib/page/instant_privacy/services/instant_privacy_service.dart b/lib/page/instant_privacy/services/instant_privacy_service.dart index 9a998f068..81ad6c18f 100644 --- a/lib/page/instant_privacy/services/instant_privacy_service.dart +++ b/lib/page/instant_privacy/services/instant_privacy_service.dart @@ -4,6 +4,7 @@ import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/core/usp/errors/usp_error.dart'; import 'package:privacy_gui/core/usp/providers/usp_client_provider.dart'; import 'package:privacy_gui/core/usp/services/usp_client.dart'; +import 'package:privacy_gui/core/utils/oui_lookup.dart'; import 'package:privacy_gui/generated/connected_devices.g.dart'; import 'package:privacy_gui/generated/mac_filter_access_points.g.dart'; import 'package:privacy_gui/page/instant_privacy/models/instant_privacy_device_ui_model.dart'; @@ -64,6 +65,7 @@ class UspInstantPrivacyService { return InstantPrivacyDeviceUIModel( mac: mac, displayName: d.hostName.isNotEmpty ? d.hostName : mac, + isPrivateMac: OuiLookup.isRandomizedMac(mac), ); }).toList(); } @@ -190,7 +192,11 @@ class UspInstantPrivacyService { // Allowed list shows all whitelisted MACs with hostname if known, else MAC final allowed = allowedDevices(macAps).map((d) { final name = hostnameByMac[d.mac] ?? d.mac; - return InstantPrivacyDeviceUIModel(mac: d.mac, displayName: name); + return InstantPrivacyDeviceUIModel( + mac: d.mac, + displayName: name, + isPrivateMac: OuiLookup.isRandomizedMac(d.mac), + ); }).toList(); return InstantPrivacyFetchResult( diff --git a/lib/page/instant_privacy/views/instant_privacy_view.dart b/lib/page/instant_privacy/views/instant_privacy_view.dart index 3ccb848cb..6bd098c53 100644 --- a/lib/page/instant_privacy/views/instant_privacy_view.dart +++ b/lib/page/instant_privacy/views/instant_privacy_view.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:privacy_gui/components/localizations/service_error_localizations.dart'; import 'package:privacy_gui/components/ui_kit_page_view.dart'; +import 'package:privacy_gui/components/views/service_error_view.dart'; +import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/localization/localization_hook.dart'; import 'package:privacy_gui/route/constants.dart'; import 'package:privacy_gui/page/_shared/components/detail_widgets.dart'; @@ -35,45 +37,36 @@ class InstantPrivacyView extends ConsumerWidget { child: (childContext, constraints) { return asyncState.when( loading: () => const Center(child: AppLoader()), - error: (error, _) => _buildError(context, ref, error), + error: (error, _) => ServiceErrorView( + error: error is ServiceError ? error : null, + title: loc(context).failedToLoadSettings, + onRetry: () => ref.invalidate(uspInstantPrivacyProvider), + ), data: (state) => _buildContent(context, ref, state), ); }, ); } - Widget _buildError(BuildContext context, WidgetRef ref, Object error) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - AppIcon.font(Icons.error_outline, - size: 48, color: Theme.of(context).colorScheme.error), - AppGap.xl(), - AppText.titleMedium(loc(context).failedToLoadSettings), - AppGap.md(), - AppText.bodyMedium(localizeServiceError(context, error)), - AppGap.xxl(), - AppButton( - label: loc(context).retry, - onTap: () => ref.invalidate(uspInstantPrivacyProvider), - ), - ], - ), - ); - } - Widget _buildContent( BuildContext context, WidgetRef ref, UspInstantPrivacyState state, ) { + final hasPrivateMacInList = state.isEnabled + ? state.allowedDevices.any((d) => d.isPrivateMac) + : state.connectedDevices.any((d) => d.isPrivateMac); + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ AppText.bodyMedium( loc(context).instantPrivacyPageDesc, ), + if (hasPrivateMacInList) ...[ + AppGap.md(), + _buildPrivateMacWarningBanner(context), + ], AppGap.lg(), _buildToggleCard(context, ref, state), AppGap.md(), @@ -205,14 +198,23 @@ class InstantPrivacyView extends ConsumerWidget { Widget _buildDeviceLayoutBlock( BuildContext context, InstantPrivacyDeviceUIModel device) { + final colorScheme = Theme.of(context).colorScheme; return LayoutBlock( padding: const EdgeInsets.all(AppSpacing.md), child: Row( children: [ + if (device.isPrivateMac) ...[ + AppBadge( + label: loc(context).privateMacLabel, + color: colorScheme.error, + textColor: colorScheme.onError, + ), + AppGap.sm(), + ], AppIcon.font( Icons.devices, size: 20, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: colorScheme.onSurfaceVariant, ), AppGap.sm(), Expanded( @@ -222,7 +224,7 @@ class InstantPrivacyView extends ConsumerWidget { AppText.bodyMedium(device.displayName), AppText.bodySmall( device.mac, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: colorScheme.onSurfaceVariant, ), ], ), @@ -232,24 +234,86 @@ class InstantPrivacyView extends ConsumerWidget { ); } + // --------------------------------------------------------------------------- + // Private (randomized) MAC warning + // --------------------------------------------------------------------------- + + /// Banner shown on page when any device uses a private MAC. + Widget _buildPrivateMacWarningBanner(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: colorScheme.errorContainer, + borderRadius: BorderRadius.circular(AppSpacing.sm), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppIcon.font( + Icons.warning_amber_rounded, + size: 20, + color: colorScheme.onErrorContainer, + ), + AppGap.sm(), + Expanded( + child: AppText.bodySmall( + loc(context).privateMacWarningDesc, + color: colorScheme.onErrorContainer, + ), + ), + ], + ), + ); + } + + /// Inline warning shown in enable dialog (title only). + Widget _buildPrivateMacDialogWarning(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.only(top: AppSpacing.md), + child: Row( + children: [ + AppIcon.font( + Icons.warning_amber_rounded, + size: 20, + color: colorScheme.error, + ), + AppGap.sm(), + Expanded( + child: AppText.labelMedium( + loc(context).privateMacWarningTitle, + color: colorScheme.error, + ), + ), + ], + ), + ); + } + // --------------------------------------------------------------------------- // Confirmation dialogs // --------------------------------------------------------------------------- Future _onEnable(BuildContext context, WidgetRef ref) async { + final connected = + ref.read(uspInstantPrivacyProvider).valueOrNull?.connectedDevices ?? + const []; + final privateMacDevices = connected.where((d) => d.isPrivateMac).toList(); final confirmed = await showAppDialog( context: context, builder: (ctx) => AppDialog( titleText: loc(context).enableInstantPrivacyTitle, - content: AppText.bodyMedium( - loc(context).enableInstantPrivacyDesc( - ref - .read(uspInstantPrivacyProvider) - .valueOrNull - ?.connectedDevices - .length ?? - 0, - ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppText.bodyMedium( + loc(context).enableInstantPrivacyDesc(connected.length), + ), + if (privateMacDevices.isNotEmpty) + _buildPrivateMacDialogWarning(context), + ], ), actions: [ AppButton.text( @@ -316,10 +380,19 @@ class InstantPrivacyView extends ConsumerWidget { WidgetRef ref, UspInstantPrivacyState state, ) async { + // Build autocomplete options from connected devices + final deviceOptions = state.connectedDevices + .map((d) => AppAutoCompleteOption( + label: d.displayName, + value: d.mac, + )) + .toList(); + await showAppDialog( context: context, builder: (ctx) => _AddMacDialog( existingDevices: state.allowedDevices, + deviceOptions: deviceOptions, onConfirm: (mac) async { Navigator.of(ctx).pop(); try { @@ -343,11 +416,13 @@ class InstantPrivacyView extends ConsumerWidget { class _AddMacDialog extends StatefulWidget { final List existingDevices; + final List deviceOptions; final Future Function(String mac) onConfirm; const _AddMacDialog({ required this.existingDevices, required this.onConfirm, + this.deviceOptions = const [], }); @override @@ -356,17 +431,33 @@ class _AddMacDialog extends StatefulWidget { class _AddMacDialogState extends State<_AddMacDialog> { final _controller = TextEditingController(); + final _focusNode = FocusNode(); String? _errorText; bool _isConfirming = false; + @override + void initState() { + super.initState(); + _focusNode.addListener(_onFocusChange); + } + @override void dispose() { + _focusNode.removeListener(_onFocusChange); + _focusNode.dispose(); _controller.dispose(); super.dispose(); } - void _onChanged(String value) { + void _onFocusChange() { + if (!_focusNode.hasFocus) { + _validate(); + } + } + + void _validate() { setState(() { + final value = _controller.text; if (value.isEmpty) { _errorText = null; return; @@ -382,6 +473,11 @@ class _AddMacDialogState extends State<_AddMacDialog> { }); } + void _onChanged(String value) { + // Don't setState here - any state change causes focus loss on Web + // Validation happens on unfocus via _onFocusChange + } + bool get _canConfirm => _controller.text.isNotEmpty && _errorText == null && @@ -413,11 +509,17 @@ class _AddMacDialogState extends State<_AddMacDialog> { children: [ AppText.bodyMedium(loc(context).enterMacAddressToAllow), AppGap.md(), - AppTextFormField( + AppSelectAutoComplete( + options: widget.deviceOptions, controller: _controller, - hintText: 'AA:BB:CC:DD:EE:FF', - onChanged: _onChanged, - externalErrorText: _localizeError(_errorText), + onSelected: (_) => _validate(), + child: AppTextField( + controller: _controller, + focusNode: _focusNode, + hintText: 'AA:BB:CC:DD:EE:FF', + onChanged: _onChanged, + errorText: _localizeError(_errorText), + ), ), ], ), diff --git a/lib/page/instant_safety/views/instant_safety_view.dart b/lib/page/instant_safety/views/instant_safety_view.dart index 155304068..ac2e63c7c 100644 --- a/lib/page/instant_safety/views/instant_safety_view.dart +++ b/lib/page/instant_safety/views/instant_safety_view.dart @@ -40,6 +40,7 @@ class UspInstantSafetyView extends ConsumerWidget { if (state.status.error != null) { return ServiceErrorView( error: state.status.error, + title: loc(context).failedToLoadSettings, onRetry: () => ref.invalidate(uspInstantSafetyProvider), ); } diff --git a/lib/page/instant_setup/models/pnp_state.dart b/lib/page/instant_setup/models/pnp_state.dart index a59d9ae85..49e9f8d6a 100644 --- a/lib/page/instant_setup/models/pnp_state.dart +++ b/lib/page/instant_setup/models/pnp_state.dart @@ -1,6 +1,6 @@ import 'package:equatable/equatable.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; import 'package:privacy_gui/page/internet_settings/models/usp_internet_settings_form.dart'; -import 'package:privacy_gui/page/topology/models/node_ui_model.dart'; import 'pnp_wifi_config.dart'; /// Whether this is a factory-default first-time setup or a reconfigure. @@ -71,12 +71,22 @@ class AdminInternetConnected extends PnpPhase { List get props => []; } -/// Critical error in admin phase. -class AdminError extends PnpPhase { - final String message; - const AdminError({required this.message}); +/// Router state could not be read (USP GET returned empty / missing fields). +/// +/// Distinct from [NoInternet]: the router did not confirm "no internet" — the +/// read itself failed, so we cannot tell the WAN state at all. The no-internet +/// troubleshooter options (restart modem / enter ISP settings) are meaningless +/// here, so this phase renders its own error card with a plain retry instead. +/// +/// [code] / [detail] carry the underlying [ServiceError] diagnostics (e.g. the +/// codegen 9998 "required fields missing" fault) for logging only — the UI +/// derives its message from the phase itself. +class AdminReadFailure extends PnpPhase { + final int? code; + final String? detail; + const AdminReadFailure({this.code, this.detail}); @override - List get props => [message]; + List get props => [code, detail]; } /// No internet detected — route to troubleshooter. @@ -138,7 +148,7 @@ class WizardInitializing extends PnpPhase { /// User is editing WiFi name / password / guest WiFi. class WizardConfiguring extends PnpPhase { final PnpWifiConfig wifiConfig; - final List meshNodes; + final List meshNodes; const WizardConfiguring({ required this.wifiConfig, diff --git a/lib/page/instant_setup/providers/pnp_notifier.dart b/lib/page/instant_setup/providers/pnp_notifier.dart index 2bb236f82..67b424021 100644 --- a/lib/page/instant_setup/providers/pnp_notifier.dart +++ b/lib/page/instant_setup/providers/pnp_notifier.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/core/usp/providers/usp_auth_coordinator.dart'; import 'package:privacy_gui/core/usp/providers/usp_mutation_lock.dart'; import 'package:privacy_gui/core/session/providers/session_provider.dart'; @@ -46,10 +47,17 @@ class PnpNotifier extends Notifier { ); await _checkInternet(); + } on ServiceError catch (e) { + // Reading device info failed (e.g. USP GET returned empty). This is a + // read failure, not "no internet" — surface it as such. + logger.e('[PnP] startPostLoginFlow read failure: $e (code=${e.code})'); + state = state.copyWith( + phase: AdminReadFailure(code: e.code, detail: '$e'), + ); } catch (e) { logger.e('[PnP] startPostLoginFlow error: $e'); state = state.copyWith( - phase: AdminError(message: '$e'), + phase: AdminReadFailure(detail: '$e'), ); } } @@ -71,9 +79,18 @@ class PnpNotifier extends Notifier { phase: NoInternet(ssid: ssid, currentWanSettings: wanSettings), ); } + } on ServiceError catch (e) { + // The WAN read threw — we could NOT determine the WAN state (router + // unreachable / USP GET returned empty). This is distinct from the router + // confirming "no internet" (which returns false above, no throw), so we + // must not collapse it into NoInternet. + logger.e('[PnP] Internet check read failure: $e (code=${e.code})'); + state = state.copyWith( + phase: AdminReadFailure(code: e.code, detail: '$e'), + ); } catch (e) { - logger.e('[PnP] Internet check failed: $e'); - state = state.copyWith(phase: const NoInternet()); + logger.e('[PnP] Internet check unexpected error: $e'); + state = state.copyWith(phase: AdminReadFailure(detail: '$e')); } } @@ -316,7 +333,9 @@ class PnpNotifier extends Notifier { try { // Re-login (WASM state lost during WiFi change) - await ref.read(uspAuthCoordinatorProvider).restoreSession(); + await ref.read(uspAuthCoordinatorProvider).restoreSession( + isRecovering: true, + ); final sn = await _svc.checkRouterIsBack(); final expectedSn = state.serialNumber; @@ -377,27 +396,6 @@ class PnpNotifier extends Notifier { // ─── No Internet Flow ─────────────────────────────────── - /// Save ISP settings and re-check internet. - Future saveIspSettingsAndCheck(PnpIspConfig config) async { - try { - await ref.read(uspMutationLockProvider).withLock(() async { - await _svc.saveIspSettings(config); - }); - - // Wait for WAN interface to come up - await Future.delayed(const Duration(seconds: 5)); - - state = state.copyWith(phase: const AdminCheckingInternet()); - await _checkInternet(); - } catch (e) { - logger.e('[PnP] ISP save failed: $e'); - state = state.copyWith( - phase: const NoInternet(), - errorMessage: '$e', - ); - } - } - /// Retry internet check after modem restart flow. Future retryInternetCheck() async { state = state.copyWith(phase: const AdminCheckingInternet()); diff --git a/lib/page/instant_setup/services/pnp_service.dart b/lib/page/instant_setup/services/pnp_service.dart index ac750b09a..71b112b00 100644 --- a/lib/page/instant_setup/services/pnp_service.dart +++ b/lib/page/instant_setup/services/pnp_service.dart @@ -1,4 +1,5 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/core/usp/errors/usp_error.dart'; import 'package:privacy_gui/core/usp/providers/usp_client_provider.dart'; import 'package:privacy_gui/core/usp/services/usp_client.dart'; @@ -13,6 +14,7 @@ import 'package:privacy_gui/generated/wi_fi_access_points.g.dart'; import 'package:privacy_gui/generated/wi_fi_ssids.g.dart'; import 'package:privacy_gui/page/_shared/models/mesh_topology_info.dart'; import 'package:privacy_gui/page/_shared/utils/mesh_topology_builder.dart'; +import 'package:privacy_gui/page/_shared/utils/wifi_guest_detection.dart'; import 'package:privacy_gui/generated/wi_fi_radios.g.dart'; import 'package:privacy_gui/page/instant_setup/models/pnp_isp_config.dart'; import 'package:privacy_gui/page/instant_setup/models/pnp_wifi_band.dart'; @@ -107,9 +109,8 @@ class PnpService { /// Fetch current WiFi SSIDs + Access Points and return structured results. /// - /// Separates main vs guest SSIDs by detecting shared radios: - /// - First SSID per radio = main network - /// - Additional SSIDs sharing a radio = guest network + /// Separates main vs guest SSIDs via the canonical alias rule: an SSID whose + /// `Alias` ends with `-guest` is a guest network (see wifi_guest_detection). /// /// Supports both unified mode (all bands share SSID) and split mode /// (each band has different SSID, e.g. Du ISP routers). @@ -148,22 +149,28 @@ class PnpService { return radios.items.where((r) => r.instancePath == radioPath).firstOrNull; } - // Separate main vs guest SSIDs by radio occupancy. - // First SSID per radio is "main", subsequent SSIDs on the same radio - // are "guest" (they share the radio via virtual AP). - final seenRadios = {}; + // Separate main vs guest SSIDs via the canonical alias rule (see + // wifi_guest_detection). Single source of truth shared across the app. final mainSsids = []; final guestSsids = []; for (final ssid in ssids.items) { - if (seenRadios.contains(ssid.lowerLayers)) { + if (isGuestSsid(ssid)) { guestSsids.add(ssid); } else { - seenRadios.add(ssid.lowerLayers); mainSsids.add(ssid); } } + // Diagnostic: multiple SSIDs but none matched the `-guest` alias rule + // usually means firmware did not provision guest aliases (see + // wifi_guest_detection). Guest/main split degrades silently otherwise. + if (guestSsids.isEmpty && ssids.items.length > 1) { + logger.w('[PnP] No SSID matched the "-guest" alias rule; ' + 'guest network will be treated as main. Aliases: ' + '${ssids.items.map((s) => s.alias ?? "null").toList()}'); + } + // Primary SSID = first enabled main SSID if (mainSsids.isEmpty) { logger.e('[PnP] No main WiFi SSIDs found on router'); @@ -299,7 +306,8 @@ class PnpService { final ssidUpdates = config.ssidInstancePaths .map((path) => WiFiSsidUpdate(instancePath: path, ssid: config.ssid)) .toList(); - await WiFiSsids.update(_usp, ssidUpdates); + final ssidResult = await WiFiSsids.update(_usp, ssidUpdates); + _throwIfNotSuccess(ssidResult, 'Main WiFi SSID update'); } if (config.isPasswordChanged) { @@ -309,7 +317,8 @@ class PnpService { keyPassphrase: config.password, )) .toList(); - await WiFiAccessPoints.update(_usp, apUpdates); + final apResult = await WiFiAccessPoints.update(_usp, apUpdates); + _throwIfNotSuccess(apResult, 'Main WiFi password update'); } } @@ -334,10 +343,12 @@ class PnpService { } if (ssidUpdates.isNotEmpty) { - await WiFiSsids.update(_usp, ssidUpdates); + final ssidResult = await WiFiSsids.update(_usp, ssidUpdates); + _throwIfNotSuccess(ssidResult, 'Main WiFi SSID update'); } if (apUpdates.isNotEmpty) { - await WiFiAccessPoints.update(_usp, apUpdates); + final apResult = await WiFiAccessPoints.update(_usp, apUpdates); + _throwIfNotSuccess(apResult, 'Main WiFi password update'); } } @@ -351,7 +362,21 @@ class PnpService { enable: config.guestEnabled, )) .toList(); - await WiFiSsids.update(_usp, guestSsidUpdates); + final ssidResult = await WiFiSsids.update(_usp, guestSsidUpdates); + _throwIfNotSuccess(ssidResult, 'Guest WiFi SSID update'); + + // Mirror enable state to AccessPoint layer (#972: SSID.Enable alone + // does not stop the AP broadcasting on this firmware). + if (config.isGuestEnabledChanged) { + final apEnableUpdates = config.guestAccessPointInstancePaths + .map((path) => WiFiAccessPointUpdate( + instancePath: path, + enable: config.guestEnabled, + )) + .toList(); + final apResult = await WiFiAccessPoints.update(_usp, apEnableUpdates); + _throwIfNotSuccess(apResult, 'Guest WiFi AP enable update'); + } } // Password @@ -362,7 +387,8 @@ class PnpService { keyPassphrase: config.guestPassword, )) .toList(); - await WiFiAccessPoints.update(_usp, guestApUpdates); + final apResult = await WiFiAccessPoints.update(_usp, guestApUpdates); + _throwIfNotSuccess(apResult, 'Guest WiFi password update'); } } @@ -370,6 +396,7 @@ class PnpService { // Collect all bands that have changes final ssidUpdates = []; final apUpdates = []; + final apEnableUpdates = []; for (final band in config.guestBands) { // Always include enable state for guest bands @@ -379,6 +406,14 @@ class PnpService { ssid: band.ssid, enable: config.guestEnabled, )); + // Mirror enable state to AccessPoint layer (#972) + if (config.isGuestEnabledChanged && + band.accessPointInstancePath.isNotEmpty) { + apEnableUpdates.add(WiFiAccessPointUpdate( + instancePath: band.accessPointInstancePath, + enable: config.guestEnabled, + )); + } } if (band.isPasswordChanged && band.accessPointInstancePath.isNotEmpty) { apUpdates.add(WiFiAccessPointUpdate( @@ -395,14 +430,29 @@ class PnpService { instancePath: band.ssidInstancePath, enable: config.guestEnabled, )); + // Mirror enable state to AccessPoint layer (#972) + if (band.accessPointInstancePath.isNotEmpty) { + apEnableUpdates.add(WiFiAccessPointUpdate( + instancePath: band.accessPointInstancePath, + enable: config.guestEnabled, + )); + } } } if (ssidUpdates.isNotEmpty) { - await WiFiSsids.update(_usp, ssidUpdates); + final ssidResult = await WiFiSsids.update(_usp, ssidUpdates); + _throwIfNotSuccess(ssidResult, 'Guest WiFi SSID update'); + } + // Write AP enable state before password updates (enable first, then configure) + if (apEnableUpdates.isNotEmpty) { + final apEnableResult = + await WiFiAccessPoints.update(_usp, apEnableUpdates); + _throwIfNotSuccess(apEnableResult, 'Guest WiFi AP enable update'); } if (apUpdates.isNotEmpty) { - await WiFiAccessPoints.update(_usp, apUpdates); + final apResult = await WiFiAccessPoints.update(_usp, apUpdates); + _throwIfNotSuccess(apResult, 'Guest WiFi password update'); } } @@ -524,4 +574,25 @@ class PnpService { throw mapUspErrorToServiceError(e); } } + + /// Throws [UspPartialFailureError] or [UspCompleteFailureError] if [result] + /// is not a complete success. [label] prefixes the error summary. + void _throwIfNotSuccess(Map result, String label) { + final parsed = UspResultParser.parseSetResult(result); + switch (parsed) { + case UspSuccess(): + return; + case UspPartialSuccess(failures: final f): + throw UspPartialFailureError( + summary: '$label partial failure: ${f.first.errorMessage}', + successPaths: [], + failures: f, + ); + case UspFailure(errors: final e): + throw UspCompleteFailureError( + summary: '$label failed: ${e.first.errorMessage}', + failures: e, + ); + } + } } diff --git a/lib/page/instant_setup/views/pnp_entry_view.dart b/lib/page/instant_setup/views/pnp_entry_view.dart index 14988ad2c..12d093d79 100644 --- a/lib/page/instant_setup/views/pnp_entry_view.dart +++ b/lib/page/instant_setup/views/pnp_entry_view.dart @@ -60,7 +60,7 @@ class _PnpEntryViewState extends ConsumerState { child: switch (pnpState.phase) { AdminCheckingInternet() => _buildCheckingInternet(context), AdminInternetConnected() => _buildLoading(context), - AdminError(message: final msg) => _buildErrorCard(context, msg), + AdminReadFailure() => _buildErrorCard(context), WizardInitializing() => _buildLoading(context), _ => _buildLoading(context), }, @@ -93,7 +93,7 @@ class _PnpEntryViewState extends ConsumerState { ); } - Widget _buildErrorCard(BuildContext context, String message) { + Widget _buildErrorCard(BuildContext context) { return AppCard( child: Padding( padding: const EdgeInsets.all(AppSpacing.lg), @@ -102,7 +102,10 @@ class _PnpEntryViewState extends ConsumerState { children: [ AppIcon.font(Icons.error_outline, size: 48, color: Colors.red), AppGap.lg(), - AppText.bodyMedium(message), + AppText.bodyMedium( + loc(context).unableToGatherDeviceInfo, + textAlign: TextAlign.center, + ), AppGap.xl(), AppButton.text( label: loc(context).tryAgain, diff --git a/lib/page/instant_setup/views/pnp_isp_settings_view.dart b/lib/page/instant_setup/views/pnp_isp_settings_view.dart index 9c7e11097..b4a58df17 100644 --- a/lib/page/instant_setup/views/pnp_isp_settings_view.dart +++ b/lib/page/instant_setup/views/pnp_isp_settings_view.dart @@ -39,6 +39,11 @@ class _PnpIspSettingsViewState extends ConsumerState { final phase = state.phase; if (phase is WizardConfiguring || phase is WizardInitializing) { context.go(RoutePath.pnp); + } else if (phase is AdminReadFailure) { + // Save succeeded but the trailing internet check could not read router + // state. Route back to the entry view, which re-runs the flow (implicit + // retry) and renders the read-failure card if it still fails. + context.go(RoutePath.pnp); } else if (state.errorMessage != null) { showFailedSnackBar(context, state.errorMessage!); } diff --git a/lib/page/instant_setup/views/pnp_no_internet_view.dart b/lib/page/instant_setup/views/pnp_no_internet_view.dart index 9de02b9aa..e2873ed64 100644 --- a/lib/page/instant_setup/views/pnp_no_internet_view.dart +++ b/lib/page/instant_setup/views/pnp_no_internet_view.dart @@ -34,6 +34,11 @@ class _PnpNoInternetViewState extends ConsumerState { ref.listen(pnpProvider, (prev, next) { if (next.phase is WizardConfiguring || next.phase is WizardInitializing) { context.go(RoutePath.pnp); + } else if (next.phase is AdminReadFailure) { + // "Try again" hit a read failure (router state unreadable) rather than a + // confirmed no-internet. Route back to the entry view, which re-runs the + // flow (implicit retry) and renders the read-failure card if it persists. + context.go(RoutePath.pnp); } }); diff --git a/lib/page/instant_setup/views/pnp_pppoe_view.dart b/lib/page/instant_setup/views/pnp_pppoe_view.dart index 1d9623d13..6782330e0 100644 --- a/lib/page/instant_setup/views/pnp_pppoe_view.dart +++ b/lib/page/instant_setup/views/pnp_pppoe_view.dart @@ -58,6 +58,11 @@ class _PnpPppoeViewState extends ConsumerState { ref.listen(pnpProvider, (prev, next) { if (next.phase is WizardConfiguring || next.phase is WizardInitializing) { context.go(RoutePath.pnp); + } else if (next.phase is AdminReadFailure) { + // Save succeeded but the trailing internet check could not read router + // state. Route back to the entry view, which re-runs the flow (implicit + // retry) and renders the read-failure card if it still fails. + context.go(RoutePath.pnp); } else if (prev?.phase is IspSaving && next.phase is NoInternet && next.errorMessage != null) { diff --git a/lib/page/instant_setup/views/pnp_static_ip_view.dart b/lib/page/instant_setup/views/pnp_static_ip_view.dart index d092fd978..cc9804a55 100644 --- a/lib/page/instant_setup/views/pnp_static_ip_view.dart +++ b/lib/page/instant_setup/views/pnp_static_ip_view.dart @@ -64,6 +64,11 @@ class _PnpStaticIpViewState extends ConsumerState { ref.listen(pnpProvider, (prev, next) { if (next.phase is WizardConfiguring || next.phase is WizardInitializing) { context.go(RoutePath.pnp); + } else if (next.phase is AdminReadFailure) { + // Save succeeded but the trailing internet check could not read router + // state. Route back to the entry view, which re-runs the flow (implicit + // retry) and renders the read-failure card if it still fails. + context.go(RoutePath.pnp); } else if (prev?.phase is IspSaving && next.phase is NoInternet && next.errorMessage != null) { diff --git a/lib/page/internet_settings/cards/usp_network_status_card.dart b/lib/page/internet_settings/cards/usp_network_status_card.dart index a35ac33fb..275a64e3f 100644 --- a/lib/page/internet_settings/cards/usp_network_status_card.dart +++ b/lib/page/internet_settings/cards/usp_network_status_card.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacy_gui/core/utils/ipv6_address.dart'; import 'package:privacy_gui/localization/localization_hook.dart'; import 'package:privacy_gui/page/_shared/models/wan_status_ui_model.dart'; import 'package:privacy_gui/page/_shared/components/dashboard_card_template.dart'; @@ -38,7 +39,7 @@ class UspNetworkStatusCard extends ConsumerWidget { mutation: () => ref .read(uspInternetSettingsProvider.notifier) .renewDhcpLease(), - successMessage: 'DHCP lease renewed', + successMessage: loc(context).leaseRenewed('DHCP'), ), ) : null, @@ -102,6 +103,12 @@ class UspNetworkStatusCard extends ConsumerWidget { label: 'IPv6', value: wan.ipv6Addresses.first, copyable: true, + // The representative address prefers global unicast; when only + // a link-local (fe80::/10) address exists it is still shown, + // tagged with a scope badge rather than hidden. See #1128. + labelTrailing: isLinkLocalIpv6(wan.ipv6Addresses.first) + ? const Ipv6ScopeBadge() + : null, ) else if (wan.ipv6Enabled) InfoGridItem(label: 'IPv6', value: 'Enabled'), diff --git a/lib/page/internet_settings/models/internet_settings_read_only_info.dart b/lib/page/internet_settings/models/internet_settings_read_only_info.dart index d7b4d5bf1..cf32654a7 100644 --- a/lib/page/internet_settings/models/internet_settings_read_only_info.dart +++ b/lib/page/internet_settings/models/internet_settings_read_only_info.dart @@ -17,11 +17,16 @@ class InternetSettingsReadOnlyInfo extends Equatable { /// Current static IP address — displayed in the status banner and renew section. final String staticIpAddress; + /// Router hostname (`Device.DeviceInfo.HostName`). Display-only; used to build + /// the `https://.local` bridge-mode management address. + final String hostName; + const InternetSettingsReadOnlyInfo({ this.currentMacAddress = '', this.pppConnectionStatus = '', this.dhcpv6Duid = '', this.staticIpAddress = '', + this.hostName = '', }); @override @@ -30,5 +35,6 @@ class InternetSettingsReadOnlyInfo extends Equatable { pppConnectionStatus, dhcpv6Duid, staticIpAddress, + hostName, ]; } diff --git a/lib/page/internet_settings/models/usp_internet_settings_form.dart b/lib/page/internet_settings/models/usp_internet_settings_form.dart index 941b0cb03..3ed365f4c 100644 --- a/lib/page/internet_settings/models/usp_internet_settings_form.dart +++ b/lib/page/internet_settings/models/usp_internet_settings_form.dart @@ -18,13 +18,14 @@ class UspInternetSettingsForm extends Equatable { final String dnsServer2; final String dnsServer3; - // === PPPoE Fields === + // === PPPoE / PPTP / L2TP Fields === final String pppUsername; final String pppPassword; final String pppoeServiceName; final String connectionTrigger; // 'AlwaysOn' | 'OnDemand' final int idleDisconnectTime; final int lcpEchoInterval; + final String serverAddress; // PPTP/L2TP VPN server hostname or IP // === VLAN === final bool vlanEnabled; @@ -58,6 +59,7 @@ class UspInternetSettingsForm extends Equatable { this.connectionTrigger = 'AlwaysOn', this.idleDisconnectTime = 0, this.lcpEchoInterval = 0, + this.serverAddress = '', this.vlanEnabled = false, this.vlanId = 0, this.mtu = 0, @@ -84,6 +86,7 @@ class UspInternetSettingsForm extends Equatable { String? connectionTrigger, int? idleDisconnectTime, int? lcpEchoInterval, + String? serverAddress, bool? vlanEnabled, int? vlanId, int? mtu, @@ -109,6 +112,7 @@ class UspInternetSettingsForm extends Equatable { connectionTrigger: connectionTrigger ?? this.connectionTrigger, idleDisconnectTime: idleDisconnectTime ?? this.idleDisconnectTime, lcpEchoInterval: lcpEchoInterval ?? this.lcpEchoInterval, + serverAddress: serverAddress ?? this.serverAddress, vlanEnabled: vlanEnabled ?? this.vlanEnabled, vlanId: vlanId ?? this.vlanId, mtu: mtu ?? this.mtu, @@ -137,6 +141,7 @@ class UspInternetSettingsForm extends Equatable { connectionTrigger, idleDisconnectTime, lcpEchoInterval, + serverAddress, vlanEnabled, vlanId, mtu, diff --git a/lib/page/internet_settings/models/usp_wan_connection_type.dart b/lib/page/internet_settings/models/usp_wan_connection_type.dart index 48a6a4ab1..e96bf3f12 100644 --- a/lib/page/internet_settings/models/usp_wan_connection_type.dart +++ b/lib/page/internet_settings/models/usp_wan_connection_type.dart @@ -3,36 +3,46 @@ enum UspWanConnectionType { dhcp, staticIp, pppoe, + pptp, + l2tp, bridge; - /// Derive the connection type from raw WAN field values. + /// Derive the connection type from the WAN interface's `AddressingType`. /// - /// [addressingType] is the primary signal; [bridgeEnabled] alone is NOT - /// sufficient because `Device.Bridging.Bridge.1.Enable` is typically `true` - /// on most routers (it controls the LAN-side L2 bridge, not WAN bridge mode). - /// Bridge mode is only active when both `bridgeEnabled` is true AND - /// `addressingType` is `DHCP` (or empty/unknown) — i.e. the router has - /// explicitly been placed into bridge mode rather than a standard DHCP config. + /// [addressingType] is the single source of truth for WAN mode: + /// - `Static` → static IP + /// - `IPCP` → PPP-based (PPPoE / PPTP / L2TP, disambiguated by [lowerLayers]) + /// - `DHCP` → DHCP + /// - empty → bridge mode (firmware sets `AddressingType=""` when the WAN + /// interface is placed into a transparent L2 bridge) + /// - any other unrecognised value → DHCP (safe fallback; only an explicitly + /// empty value means bridge, so a future/transient value is not + /// misclassified as bridge). + /// + /// [lowerLayers] disambiguates PPP-based protocols by checking the tunnel + /// reference in `PPP.Interface.LowerLayers` (GRE → PPTP, L2TPv2 → L2TP). + /// + /// `Device.Bridging.Bridge.{i}.Enable` is deliberately NOT consulted: it + /// controls the LAN-side L2 bridge and is `true` on most routers regardless + /// of WAN bridge mode. static UspWanConnectionType fromRawFields({ required String addressingType, - required bool bridgeEnabled, + String lowerLayers = '', }) { switch (addressingType) { case 'Static': return staticIp; case 'IPCP': + if (lowerLayers.contains('GRE.Tunnel')) return pptp; + if (lowerLayers.contains('L2TPv2.Tunnel')) return l2tp; return pppoe; case 'DHCP': return dhcp; default: - // Only treat as bridge when addressingType is absent/unknown AND - // bridgeEnabled is explicitly true. - // TODO: This detection is fragile — bridgeEnabled (Device.Bridging. - // Bridge.1.Enable) is typically always true on most routers (LAN-side - // L2 bridge). Proper detection should check whether the WAN interface - // is configured as a bridge port via Device.Bridging.Bridge.{i}.Port. - if (bridgeEnabled && addressingType.isEmpty) return bridge; - return dhcp; + // Only an explicitly empty AddressingType signals bridge mode; any + // other unknown value falls back to DHCP rather than misclassifying + // as bridge. + return addressingType.isEmpty ? bridge : dhcp; } } @@ -41,6 +51,8 @@ enum UspWanConnectionType { dhcp => 'Automatic Configuration - DHCP', staticIp => 'Static IP', pppoe => 'PPPoE', + pptp => 'PPTP', + l2tp => 'L2TP', bridge => 'Bridge Mode', }; @@ -49,6 +61,37 @@ enum UspWanConnectionType { dhcp => 'DHCP', staticIp => 'Static', pppoe => 'IPCP', + pptp => 'IPCP', + l2tp => 'IPCP', bridge => '', // issue #14: empty string = proto=none }; + + /// Minimum MTU accepted for this connection type (protocol-independent). + int get mtuMin => 576; + + /// Maximum MTU accepted for this connection type. Varies by protocol + /// overhead: PPPoE reserves 8 bytes (PPP header), PPTP/L2TP reserve 40 bytes + /// (tunnel overhead); the rest use the Ethernet standard 1500. + int get mtuMax => switch (this) { + pppoe => 1492, // 1500 - 8 (PPP header) + pptp || l2tp => 1460, // tunnel overhead + _ => 1500, // Ethernet standard (DHCP, Static, Bridge) + }; + + /// Clamp [mtu] into this type's valid range: values already within + /// `[mtuMin, mtuMax]` are kept; anything outside (too low or too high) falls + /// back to [mtuMax]. Used when switching connection types so a previously + /// valid MTU is preserved when it still fits, and reset to the max otherwise. + int clampMtu(int mtu) => (mtu >= mtuMin && mtu <= mtuMax) ? mtu : mtuMax; + + /// Whether this type uses a PPP.Interface (credentials, LowerLayers). + bool get isPppBased => this == pppoe || this == pptp || this == l2tp; + + /// The LowerLayers value for PPP.Interface when switching to this type. + String? get pppLowerLayers => switch (this) { + pppoe => 'Device.Ethernet.Link.2', + pptp => 'Device.GRE.Tunnel.1.Interface.1', + l2tp => 'Device.L2TPv2.Tunnel.1.Interface.1', + _ => null, + }; } diff --git a/lib/page/internet_settings/providers/usp_internet_settings_form_validator.dart b/lib/page/internet_settings/providers/usp_internet_settings_form_validator.dart index 5a073a76a..6963b7f00 100644 --- a/lib/page/internet_settings/providers/usp_internet_settings_form_validator.dart +++ b/lib/page/internet_settings/providers/usp_internet_settings_form_validator.dart @@ -38,6 +38,13 @@ bool _validateIpv4Fields(UspInternetSettingsForm form) { return form.pppUsername.isNotEmpty && form.pppPassword.isNotEmpty && (form.connectionTrigger != 'OnDemand' || form.idleDisconnectTime > 0); + case UspWanConnectionType.pptp: + case UspWanConnectionType.l2tp: + return form.serverAddress.isNotEmpty && + _isValidHostnameOrIp(form.serverAddress) && + form.pppUsername.isNotEmpty && + form.pppPassword.isNotEmpty && + (form.connectionTrigger != 'OnDemand' || form.idleDisconnectTime > 0); case UspWanConnectionType.bridge: return true; } @@ -79,6 +86,9 @@ bool _validateOptionalFields(UspInternetSettingsForm form) { // MTU must be in valid range: 576 (IPv4 RFC 791 min) to max by protocol final mtuMax = switch (form.connectionType) { UspWanConnectionType.pppoe => 1492, // 1500 - 8 (PPP header) + UspWanConnectionType.pptp || + UspWanConnectionType.l2tp => + 1460, // tunnel overhead _ => 1500, // Ethernet standard (DHCP, Static) }; if (form.mtu < 576 || form.mtu > mtuMax) return false; @@ -117,6 +127,16 @@ final _macPattern = RegExp( bool _isValidMac(String value) => _macPattern.hasMatch(value); +final _hostnamePattern = RegExp( + r'^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?$', +); + +bool _isValidHostnameOrIp(String value) { + if (value.isEmpty) return false; + if (_ipAddressRule.validate(value)) return true; + return _hostnamePattern.hasMatch(value); +} + final _ipv6Rule = IPv6WithReservedRule(); /// Validate IPv6 CIDR notation (e.g. 2001:db8::/32). diff --git a/lib/page/internet_settings/providers/usp_internet_settings_notifier.dart b/lib/page/internet_settings/providers/usp_internet_settings_notifier.dart index 49042c0ef..93d9aabed 100644 --- a/lib/page/internet_settings/providers/usp_internet_settings_notifier.dart +++ b/lib/page/internet_settings/providers/usp_internet_settings_notifier.dart @@ -3,6 +3,7 @@ import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/core/utils/logger.dart'; import 'package:privacy_gui/core/usp/providers/usp_mutation_lock.dart'; import 'package:privacy_gui/core/usp/providers/usp_client_provider.dart'; +import 'package:privacy_gui/core/usp/providers/sse_providers.dart'; import 'package:privacy_gui/framework/preservable_contract.dart'; import 'package:privacy_gui/framework/preservable_notifier_mixin.dart'; import 'package:privacy_gui/page/internet_settings/models/internet_settings_feature_state.dart'; @@ -49,15 +50,16 @@ class UspInternetSettingsNotifier with PreservableAutoDisposeNotifierMixin { - /// One-shot guard: set during [performSave] when connection type was NOT - /// changed, consumed by [performFetch] to prevent transient empty - /// `addressingType` from the device causing Bridge misdetection. + /// One-shot guard for a device-timing race (#759): immediately after a save + /// that did NOT change the connection type, the device can transiently report + /// an empty `addressingType`. Under the AddressingType-only detection rule + /// (see [UspWanConnectionType.fromRawFields]) that empty value would read as + /// Bridge. This guard is set in [performSave] when the type was unchanged and + /// consumed by the next [performFetch] to preserve the known type. /// - // TODO: Remove this guard once Bridge Mode is properly implemented via - // TR-181 `Device.Bridging.Bridge.{i}.Port.{i}` — see the tracking issue - // for details. The current Bridge detection relies on empty addressingType - // + bridgeEnabled, which is fragile because bridgeEnabled is always true - // on most routers (LAN-side L2 bridge). + /// This is independent of detection correctness and must NOT be removed: + /// detection keys on a real device value, while this protects against a + /// transient one during the save→refetch window. UspWanConnectionType? _preservedConnectionType; @override @@ -83,10 +85,12 @@ class UspInternetSettingsNotifier detail: 'USP service not available'); } - if (!usp.isAuthenticated) { - throw const ConnectivityError(detail: 'USP not authenticated'); - } - + // No auth gate here: the router already guards all /usp routes on + // loginType (RA-aware), and the WAN service surfaces real errors. The raw + // usp.isAuthenticated flag is a WASM transport signal that stays false in + // Remote Assistance (authToken bypass), so gating on it here wrongly + // blocked RA sessions (issue #1119). The usp == null gate above (and the + // service provider itself) still cover the "USP unavailable" case. final service = ref.read(uspInternetSettingsServiceProvider); final result = await service.fetchSettings(); @@ -180,6 +184,45 @@ class UspInternetSettingsNotifier } } + // --------------------------------------------------------------------------- + // save — override the mixin's default (performSave -> markAsSaved -> refetch) + // to special-case the entering-bridge transition. + // --------------------------------------------------------------------------- + + @override + Future save() async { + // Detect the entering-bridge transition BEFORE saving: `original` is the + // baseline type, `edited` is what the user just chose. markAsSaved() below + // collapses original into current, so this must be read up front. + final enteringBridge = + state.original.connectionType != UspWanConnectionType.bridge && + state.edited.connectionType == UspWanConnectionType.bridge; + + await performSave(); + markAsSaved(); + + if (enteringBridge) { + // Entering bridge makes the router unreachable on this origin (the WAN + // port joins br-lan and the local DHCP server is disabled). Two effects + // are handled here, both specific to this terminal transition: + // + // 1. Drop SSE intentionally. disconnect() sets _intentionalDisconnect, + // which stops the reconnect backoff and suppresses onReconnectFailed, + // so the app-level recovery flow (2 reconnect failures -> + // waitingForRecovery) never fires on top of the bridge redirect + // dialog. + // 2. Skip the post-save re-fetch. The SET already succeeded; the device + // is now gone from this origin, so fetch(forceRemote: true) would only + // time out (~15s per GET) and surface a spurious "something went + // wrong" error for an operation that actually succeeded. The redirect + // dialog is the only valid next step from here. + await ref.read(sseManagerProvider)?.disconnect(); + return state; + } + + return fetch(forceRemote: true); + } + // --------------------------------------------------------------------------- // Edit mode // --------------------------------------------------------------------------- @@ -227,9 +270,16 @@ class UspInternetSettingsNotifier final current = state.settings.current; var form = current.form.copyWith(connectionType: type); - // Reset type-specific fields when switching away + // Normalize MTU for the new type (issue #1083). Bridge always uses auto + // (mtu = 0). For every other type, keep the current MTU when it still fits + // the type's range, otherwise fall back to the type's max — this both + // clamps an over-limit value (e.g. 1500 → 1492 on PPPoE) and auto-fills a + // sensible default when the previous value was auto/empty (e.g. leaving + // bridge mode). if (type == UspWanConnectionType.bridge) { form = form.copyWith(mtu: 0); + } else { + form = form.copyWith(mtu: type.clampMtu(form.mtu)); } state = state.copyWith( diff --git a/lib/page/internet_settings/providers/wan_data_provider.dart b/lib/page/internet_settings/providers/wan_data_provider.dart index 3807de3f7..96fd75ea4 100644 --- a/lib/page/internet_settings/providers/wan_data_provider.dart +++ b/lib/page/internet_settings/providers/wan_data_provider.dart @@ -1,19 +1,22 @@ import 'package:equatable/equatable.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:privacy_gui/core/utils/logger.dart'; import 'package:privacy_gui/core/usp/providers/sse_invalidation_provider.dart'; +import 'package:privacy_gui/framework/diagnostic_loggable.dart'; import 'package:privacy_gui/page/_shared/models/wan_status_ui_model.dart'; import 'package:privacy_gui/page/internet_settings/services/usp_wan_data_service.dart'; // ── Data Model ── -class WanData extends Equatable { +class WanData extends Equatable with DiagnosticLoggable { final WanStatusUIModel model; const WanData({required this.model}); @override - List get props => [model]; + String get diagnosticName => 'WanData'; + + @override + Map get namedProps => {'model': model}; } // ── Provider ── @@ -33,7 +36,6 @@ class WanDataNotifier extends AsyncNotifier { // SSE listener: WAN status changes (link up/down, IP changes) ref.listen(sseInvalidationProvider, (_, next) { if (next.value == InvalidationDomain.wanStatus) { - logger.d('[USP][WanData]: SSE invalidation received, refreshing'); ref.invalidateSelf(); } }); @@ -45,8 +47,6 @@ class WanDataNotifier extends AsyncNotifier { final svc = ref.read(uspWanDataServiceProvider); final model = await svc.fetch(); - logger.d('[USP][WanData]: Fetched — ip=${model.ipAddress}, ' - 'isUp=${model.isUp}, gateway=${model.gateway}'); return WanData(model: model); } } diff --git a/lib/page/internet_settings/services/usp_internet_settings_service.dart b/lib/page/internet_settings/services/usp_internet_settings_service.dart index 5d353fcd0..fae6c3166 100644 --- a/lib/page/internet_settings/services/usp_internet_settings_service.dart +++ b/lib/page/internet_settings/services/usp_internet_settings_service.dart @@ -1,7 +1,11 @@ +import 'dart:async'; + import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/core/usp/errors/usp_error.dart'; import 'package:privacy_gui/core/utils/logger.dart'; +import 'package:privacy_gui/generated/gre_tunnel.g.dart'; import 'package:privacy_gui/generated/ipv6settings.g.dart'; +import 'package:privacy_gui/generated/l2tp_tunnel.g.dart'; import 'package:privacy_gui/generated/ppp_interface.g.dart'; import 'package:privacy_gui/generated/vlan_termination.g.dart'; import 'package:privacy_gui/generated/wan_bridge.g.dart'; @@ -25,11 +29,24 @@ class UspInternetSettingsService { UspInternetSettingsService(this._usp); + /// Timeout for the terminal bridge-mode SET. + /// + /// Entering bridge is terminal-by-design: on receiving the + /// `AddressingType=""` SET the firmware applies bridge mode and bounces the + /// LAN link (fw >= 1.2.2.26070203) / reloads the network within ~2s, which + /// tears down the very connection carrying this request's response. Verified + /// on-device: obuspa applies the SET and returns a SET_RESP over its local + /// UDS (rc=0), but the app never receives the HTTP response because the + /// transport is already gone. 4s leaves headroom over the observed ~2s + /// disconnect without making the user wait the full 15s throttler timeout on + /// a SET that has already succeeded. See [_applyBridgeMode]. + static const _bridgeSetTimeout = Duration(seconds: 4); + // --------------------------------------------------------------------------- // Fetch // --------------------------------------------------------------------------- - /// Fetch WAN, IPv6, PPP, and VLAN settings in parallel. + /// Fetch WAN, IPv6, PPP, VLAN, and tunnel settings in parallel. Future fetchSettings() async { try { final results = await Future.wait([ @@ -37,18 +54,24 @@ class UspInternetSettingsService { Ipv6Settings.fetch(_usp), PppInterface.fetch(_usp), VlanTermination.fetch(_usp), + GreTunnel.fetch(_usp), + L2tpTunnel.fetch(_usp), + _fetchHostName(), ]); final wan = results[0] as WanSettings; final ipv6 = results[1] as Ipv6Settings; final ppp = results[2] as PppInterface; final vlan = results[3] as VlanTermination; + final gre = results[4] as GreTunnel; + final l2tp = results[5] as L2tpTunnel; + final hostName = results[6] as String; final pppInstance = ppp.items.isNotEmpty ? ppp.items.first : null; final vlanInstance = vlan.items.isNotEmpty ? vlan.items.first : null; return InternetSettingsFetchResult( - form: _buildForm(wan, ipv6, pppInstance, vlanInstance), - readOnlyInfo: _buildReadOnlyInfo(wan, pppInstance), + form: _buildForm(wan, ipv6, pppInstance, vlanInstance, gre, l2tp), + readOnlyInfo: _buildReadOnlyInfo(wan, pppInstance, hostName), pppInstancePath: pppInstance?.instancePath, vlanInstancePath: vlanInstance?.instancePath, debugAddressingType: wan.addressingType, @@ -61,6 +84,14 @@ class UspInternetSettingsService { } } + /// Targeted GET of the router hostname. Returns '' on any missing value so a + /// hostname-less device degrades gracefully (no bridge redirect target). + Future _fetchHostName() async { + const path = 'Device.DeviceInfo.HostName'; + final response = await _usp.get([path]); + return (response[path] ?? '') as String; + } + // --------------------------------------------------------------------------- // Build form — DNS comma-separated split happens here // --------------------------------------------------------------------------- @@ -70,6 +101,8 @@ class UspInternetSettingsService { Ipv6Settings ipv6, PppInterfaceInstance? ppp, VlanTerminationInstance? vlan, + GreTunnel gre, + L2tpTunnel l2tp, ) { // Split comma-separated DNS into 3 fields final dnsParts = wan.dnsServers @@ -78,11 +111,21 @@ class UspInternetSettingsService { .where((s) => s.isNotEmpty) .toList(); + final lowerLayers = ppp?.lowerLayers ?? ''; + final connectionType = UspWanConnectionType.fromRawFields( + addressingType: wan.addressingType, + lowerLayers: lowerLayers, + ); + + // Resolve server address from the appropriate tunnel + final serverAddress = switch (connectionType) { + UspWanConnectionType.pptp => gre.remoteEndpoints, + UspWanConnectionType.l2tp => l2tp.remoteEndpoints, + _ => '', + }; + return UspInternetSettingsForm( - connectionType: UspWanConnectionType.fromRawFields( - addressingType: wan.addressingType, - bridgeEnabled: wan.bridgeEnabled, - ), + connectionType: connectionType, staticIpAddress: wan.staticIpAddress, subnetMask: wan.subnetMask, defaultGateway: wan.defaultGateway, @@ -95,6 +138,7 @@ class UspInternetSettingsService { connectionTrigger: ppp?.connectionTrigger ?? 'AlwaysOn', idleDisconnectTime: ppp?.idleDisconnectTime ?? 0, lcpEchoInterval: ppp?.lcpEcho ?? 0, + serverAddress: serverAddress, vlanEnabled: vlan?.enable ?? false, vlanId: vlan?.vlanId ?? 0, mtu: wan.mtu, @@ -111,11 +155,13 @@ class UspInternetSettingsService { InternetSettingsReadOnlyInfo _buildReadOnlyInfo( WanSettings wan, PppInterfaceInstance? ppp, + String hostName, ) { return InternetSettingsReadOnlyInfo( currentMacAddress: '', // MAC Clone disabled pppConnectionStatus: ppp?.connectionStatus ?? '', staticIpAddress: wan.staticIpAddress, + hostName: hostName, ); } @@ -126,12 +172,17 @@ class UspInternetSettingsService { /// Save all changed fields by comparing [original] vs [edited]. /// /// Orchestration order: - /// 1. Handle PPP instance lifecycle (Add/Delete) - /// 2. Handle VLAN instance lifecycle (Add/Delete) - /// 3. Save singleton WAN fields - /// 4. Save PPP instance fields (if instance exists) - /// 5. Save VLAN instance fields (if instance exists) - /// 6. Save IPv6 fields + /// 1. Handle PPP instance lifecycle (Add if needed) + /// 2. Save singleton WAN fields (mode switch or field edit) — must precede + /// tunnel writes so the firmware syncs `proto` and the GRE/L2TPv2 tunnel + /// instance becomes valid (per Architecture issue #119) + /// 3. Set PPP LowerLayers (tunnel type selection) + /// 4. Set tunnel RemoteEndpoints (server address) + /// 5. Save PPP instance fields (credentials, connection mode) + /// 6. Save VLAN instance fields (if instance exists) + /// 7. Save IPv6 fields + /// 8. Apply terminal bridge SET last (drops the connection; see + /// [_applyBridgeMode]) so every other SET lands on a live connection Future saveAll( UspInternetSettingsForm original, UspInternetSettingsForm edited, { @@ -145,27 +196,47 @@ class UspInternetSettingsService { currentInstancePath: pppInstancePath, ); - // Step 2: WAN mode switch or field edit (per-mode dispatch) + // Step 2: WAN mode switch or field edit (per-mode dispatch). Setting + // AddressingType=IPCP first lets the firmware sync proto=pptp/l2tp so the + // tunnel instance becomes valid before its RemoteEndpoints is written. + // + // Entering bridge is terminal: the bridge SET drops this connection (see + // _applyBridgeMode). Defer it so the FW-spec VLAN/IPv6 SETs below still + // land on a live connection; it is sent last, in Step 6. final typeChanged = original.connectionType != edited.connectionType; - final switchingToPppoe = - typeChanged && edited.connectionType == UspWanConnectionType.pppoe; - await _saveWanSettings(original, edited); + final switchingToPppBased = + typeChanged && edited.connectionType.isPppBased; + final enteringBridge = + typeChanged && edited.connectionType == UspWanConnectionType.bridge; + await _saveWanSettings(original, edited, deferBridge: enteringBridge); + + // Step 3: Set LowerLayers on PPP instance (tunnel type selection) + if (pppPath != null && edited.connectionType.isPppBased) { + await _savePppLowerLayers(original, edited, pppPath); + } - // Step 3: PPP instance fields (skip username/password if already sent + // Step 4: Set tunnel RemoteEndpoints (server address) + await _saveTunnelRemoteEndpoints(original, edited); + + // Step 5: PPP instance fields (skip username/password if already sent // in the ordered Set above) - if (pppPath != null && - edited.connectionType == UspWanConnectionType.pppoe) { + if (pppPath != null && edited.connectionType.isPppBased) { await _savePppSettings(original, edited, pppPath, - skipCredentials: switchingToPppoe); + skipCredentials: switchingToPppBased); } - // Step 4: VLAN settings (always use SET on existing instance) + // Step 6: VLAN settings (always use SET on existing instance) if (vlanInstancePath != null) { await _saveVlanSettings(original, edited, vlanInstancePath); } - // Step 5: IPv6 fields + // Step 7: IPv6 fields await _saveIpv6Settings(original, edited); + + // Step 8: terminal bridge SET — last, once every other SET has landed. + if (enteringBridge) { + await _applyBridgeMode(); + } } catch (e) { if (e is ServiceError) rethrow; throw mapUspErrorToServiceError(e); @@ -179,16 +250,17 @@ class UspInternetSettingsService { /// Returns the PPP instance path to use for subsequent Set operations, /// or null if no PPP instance exists after this step. /// - /// Only creates a new instance when switching TO PPPoE and none exists. - /// Never deletes — the instance persists across mode switches. + /// Only creates a new instance when switching TO a PPP-based type and none + /// exists. Never deletes — the instance persists across mode switches. Future _handlePppLifecycle( UspInternetSettingsForm edited, { String? currentInstancePath, }) async { - final isPppoe = edited.connectionType == UspWanConnectionType.pppoe; + final isPppBased = edited.connectionType.isPppBased; - if (isPppoe && currentInstancePath == null) { - logger.d('[USP][WAN]: Adding PPP.Interface instance for PPPoE'); + if (isPppBased && currentInstancePath == null) { + logger.d('[USP][WAN]: Adding PPP.Interface instance for ' + '${edited.connectionType.name}'); final result = await PppInterface.add(_usp, [{}]); final parsedResult = UspResultParser.parseAddResult(result); if (parsedResult is UspSuccess>) { @@ -207,10 +279,16 @@ class UspInternetSettingsService { // WAN singleton save — DNS merge happens here // --------------------------------------------------------------------------- + /// Saves the WAN singleton fields for the target mode. + /// + /// When [deferBridge] is true and the edit is an entering-bridge transition, + /// the terminal bridge SET is skipped here so [saveAll] can send it last, + /// after the FW-spec VLAN/IPv6 SETs have landed on a still-live connection. Future _saveWanSettings( UspInternetSettingsForm original, - UspInternetSettingsForm edited, - ) async { + UspInternetSettingsForm edited, { + bool deferBridge = false, + }) async { final typeChanged = original.connectionType != edited.connectionType; if (typeChanged) { @@ -241,8 +319,20 @@ class UspInternetSettingsService { allowPartial: true, )); + case UspWanConnectionType.pptp: + case UspWanConnectionType.l2tp: + _handleSetResult(await WanPppoe.update( + _usp, + pppUsername: edited.pppUsername, + pppPassword: edited.pppPassword, + addressingType: 'IPCP', + allowPartial: true, + )); + case UspWanConnectionType.bridge: - _handleSetResult(await WanBridge.update(_usp, addressingType: '')); + // When deferred, saveAll sends the terminal bridge SET last (after + // VLAN/IPv6) via _applyBridgeMode(); otherwise apply it here. + if (!deferBridge) await _applyBridgeMode(); } } else { switch (edited.connectionType) { @@ -266,14 +356,73 @@ class UspInternetSettingsService { break; case UspWanConnectionType.pppoe: + case UspWanConnectionType.pptp: + case UspWanConnectionType.l2tp: break; } } - // MTU is mode-independent — update via WanSettings if changed - final mtuDiff = _diff(original.mtu, edited.mtu); - if (mtuDiff != null) { - _handleSetResult(await WanSettings.update(_usp, mtu: mtuDiff)); + // MTU is mode-independent — update via WanSettings if changed. + // + // Bridge is the exception: switching to bridge resets the form's mtu to 0 + // as a sentinel, and the FW rejects MaxMTUSize=0 (valid range 64..65535, + // errorCode 7012) — confirmed with the FW team that 0 is NOT a valid "auto" + // value. Sending it would abort saveAll before the terminal bridge SET, so + // skip the MTU SET entirely when the target mode is bridge (MTU has no + // meaning once the WAN port joins br-lan). + if (edited.connectionType != UspWanConnectionType.bridge) { + final mtuDiff = _diff(original.mtu, edited.mtu); + if (mtuDiff != null) { + _handleSetResult(await WanSettings.update(_usp, mtu: mtuDiff)); + } + } + } + + // --------------------------------------------------------------------------- + // Bridge mode apply — terminal, fire-and-forget by design + // --------------------------------------------------------------------------- + + /// Applies bridge mode via the WAN `AddressingType=""` SET. + /// + /// This SET is terminal-by-design (see [_bridgeSetTimeout]): the firmware + /// applies bridge mode and drops the connection carrying the response, so a + /// transport-level timeout/network error on THIS SET is the expected + /// signature of success — the SET was received and applied on-device before + /// the disconnect. Those two cases are swallowed. + /// + /// Any error the router actively returns BEFORE the disconnect — a fault code + /// mapped to a validation / resource / partial / auth / unexpected + /// [ServiceError] — means the SET was rejected. Those propagate so the user + /// still sees the failure; a real config failure is never hidden. + Future _applyBridgeMode() async { + try { + _handleSetResult( + await WanBridge.update(_usp, addressingType: '') + .timeout(_bridgeSetTimeout), + ); + } on TimeoutException { + // No response within the budget: the firmware applied bridge mode and + // dropped the connection, so the SET_RESP can never arrive. Expected + // success. (Future.timeout keeps an error listener on the underlying + // request, so its eventual late error is consumed, not left unhandled.) + logger.i( + '[USP][WAN]: bridge SET timed out after ${_bridgeSetTimeout.inSeconds}s ' + '— treating as success (firmware dropped the connection applying bridge mode)'); + } catch (e) { + // Reached when the request fails BEFORE the timeout. A transport / + // connectivity error is the same disconnect signature → success. But + // anything the router actively rejected — a fault code mapped to + // validation/resource/auth, or a partial/complete failure surfaced by + // _handleSetResult (already a ServiceError) — propagates, so a genuine + // config failure is never swallowed. + if (e is ServiceError) rethrow; + final mapped = mapUspErrorToServiceError(e); + if (mapped is NetworkError || mapped is ConnectivityError) { + logger.i('[USP][WAN]: bridge SET hit a transport error ' + '— treating as success (firmware dropped the connection applying bridge mode)'); + return; + } + throw mapped; } } @@ -309,6 +458,58 @@ class UspInternetSettingsService { )); } + // --------------------------------------------------------------------------- + // PPP LowerLayers — sets the tunnel type reference + // --------------------------------------------------------------------------- + + Future _savePppLowerLayers( + UspInternetSettingsForm original, + UspInternetSettingsForm edited, + String instancePath, + ) async { + final targetLowerLayers = edited.connectionType.pppLowerLayers; + if (targetLowerLayers == null) return; + + final originalLowerLayers = original.connectionType.pppLowerLayers ?? ''; + if (originalLowerLayers == targetLowerLayers) return; + + logger.d('[USP][WAN]: Setting LowerLayers to $targetLowerLayers'); + _handleSetResult(await PppInterface.update( + _usp, + [ + PppInterfaceInstanceUpdate( + instancePath: instancePath, + lowerLayers: targetLowerLayers, + ) + ], + )); + } + + // --------------------------------------------------------------------------- + // Tunnel RemoteEndpoints — server address for PPTP/L2TP + // --------------------------------------------------------------------------- + + Future _saveTunnelRemoteEndpoints( + UspInternetSettingsForm original, + UspInternetSettingsForm edited, + ) async { + final serverDiff = _diff(original.serverAddress, edited.serverAddress); + if (serverDiff == null) return; + + switch (edited.connectionType) { + case UspWanConnectionType.pptp: + logger.d('[USP][WAN]: Setting GRE RemoteEndpoints to $serverDiff'); + _handleSetResult( + await GreTunnel.update(_usp, remoteEndpoints: serverDiff)); + case UspWanConnectionType.l2tp: + logger.d('[USP][WAN]: Setting L2TP RemoteEndpoints to $serverDiff'); + _handleSetResult( + await L2tpTunnel.update(_usp, remoteEndpoints: serverDiff)); + default: + break; + } + } + // --------------------------------------------------------------------------- // VLAN instance save // --------------------------------------------------------------------------- @@ -357,8 +558,7 @@ class UspInternetSettingsService { Future renewDhcpLease() async { try { - final result = await WanOperations.renewDhcpLease(_usp); - _handleOperateResult(result); + await WanOperations.renewDhcpLease(_usp); } catch (e) { if (e is ServiceError) rethrow; throw mapUspErrorToServiceError(e); @@ -367,8 +567,7 @@ class UspInternetSettingsService { Future renewDhcpv6Lease() async { try { - final result = await WanOperations.renewDhcpv6Lease(_usp); - _handleOperateResult(result); + await WanOperations.renewDhcpv6Lease(_usp); } catch (e) { if (e is ServiceError) rethrow; throw mapUspErrorToServiceError(e); @@ -413,30 +612,6 @@ class UspInternetSettingsService { ); } } - - /// Parse and validate OPERATE result using standard UspResultParser (Strict mode). - void _handleOperateResult(Map result) { - final parsed = UspResultParser.parseOperateResult(result); - switch (parsed) { - case UspSuccess(): - break; - case UspPartialSuccess( - :final errorSummary, - :final successes, - :final failures - ): - throw UspPartialFailureError( - summary: 'WAN operation partial failure: $errorSummary', - successPaths: successes.map((s) => s.requestedPath).toList(), - failures: failures, - ); - case UspFailure(:final errorSummary, :final errors): - throw UspCompleteFailureError( - summary: 'WAN operation failed: $errorSummary', - failures: errors, - ); - } - } } /// Result of [UspInternetSettingsService.fetchSettings]. diff --git a/lib/page/internet_settings/services/usp_wan_data_service.dart b/lib/page/internet_settings/services/usp_wan_data_service.dart index e4cef3a7f..4298a1cb1 100644 --- a/lib/page/internet_settings/services/usp_wan_data_service.dart +++ b/lib/page/internet_settings/services/usp_wan_data_service.dart @@ -1,5 +1,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:privacy_gui/core/errors/service_error.dart'; +import 'package:privacy_gui/core/utils/ipv6_address.dart'; import 'package:privacy_gui/core/utils/logger.dart'; import 'package:privacy_gui/core/usp/errors/usp_error.dart'; import 'package:privacy_gui/core/usp/providers/usp_client_provider.dart'; @@ -91,12 +92,21 @@ class UspWanDataService { } } + // TR-181 returns IPv6 addresses in instance order, which frequently puts + // the link-local (fe80::/10) address first. The WAN widget shows a single + // representative address (ipv6Addresses.first), which must prefer the + // globally routable one. We keep every address (including link-local) and + // only reorder so global unicast wins; the UI marks a link-local address + // with a scope badge rather than hiding it, so a WAN with no global/ULA + // prefix still shows its link-local address instead of nothing. + // See linksys/PrivacyGUI#1128. final ipv6Addresses = ipv6.items .map((addr) => addr.ipAddress) .where((ip) => ip.isNotEmpty) .toList(); + final orderedIpv6Addresses = preferGlobalIpv6First(ipv6Addresses); - return (gateway: gateway, ipv6Addresses: ipv6Addresses); + return (gateway: gateway, ipv6Addresses: orderedIpv6Addresses); } catch (e) { logger.w('[USP][WanData]: Gateway/IPv6 fetch failed: $e'); return (gateway: '', ipv6Addresses: const []); diff --git a/lib/page/internet_settings/views/components/_components.dart b/lib/page/internet_settings/views/components/_components.dart index a5c7f6830..077a75453 100644 --- a/lib/page/internet_settings/views/components/_components.dart +++ b/lib/page/internet_settings/views/components/_components.dart @@ -1,3 +1,4 @@ export 'usp_connection_status_banner.dart'; +export 'usp_connection_type_label.dart'; export 'usp_renew_action_card.dart'; export 'usp_section_card.dart'; diff --git a/lib/page/internet_settings/views/components/usp_connection_status_banner.dart b/lib/page/internet_settings/views/components/usp_connection_status_banner.dart index 11d2b459d..1d8588d42 100644 --- a/lib/page/internet_settings/views/components/usp_connection_status_banner.dart +++ b/lib/page/internet_settings/views/components/usp_connection_status_banner.dart @@ -1,8 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:privacy_gui/localization/localization_hook.dart'; import 'package:privacy_gui/page/_shared/components/usp_status_dot.dart'; -import 'package:privacy_gui/page/internet_settings/models/usp_wan_connection_type.dart'; import 'package:privacy_gui/page/internet_settings/models/internet_settings_feature_state.dart'; +import 'package:privacy_gui/page/internet_settings/views/components/usp_connection_type_label.dart'; import 'package:ui_kit_library/ui_kit.dart'; /// A prominent banner at the top of the Internet Settings page. @@ -44,7 +43,7 @@ class UspConnectionStatusBanner extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ AppText.labelLarge( - _connectionTypeLabel(context, connectionType), + connectionType.localizedLabel(context), ), AppGap.xs(), AppText.bodySmall( @@ -63,14 +62,4 @@ class UspConnectionStatusBanner extends StatelessWidget { ), ); } - - String _connectionTypeLabel(BuildContext context, UspWanConnectionType type) { - final l = loc(context); - return switch (type) { - UspWanConnectionType.dhcp => l.connectionTypeDhcp, - UspWanConnectionType.staticIp => l.staticIp, - UspWanConnectionType.pppoe => l.connectionTypePppoe, - UspWanConnectionType.bridge => l.connectionTypeBridge, - }; - } } diff --git a/lib/page/internet_settings/views/components/usp_connection_type_label.dart b/lib/page/internet_settings/views/components/usp_connection_type_label.dart new file mode 100644 index 000000000..d38476f1d --- /dev/null +++ b/lib/page/internet_settings/views/components/usp_connection_type_label.dart @@ -0,0 +1,23 @@ +import 'package:flutter/widgets.dart'; +import 'package:privacy_gui/localization/localization_hook.dart'; +import 'package:privacy_gui/page/internet_settings/models/usp_wan_connection_type.dart'; + +/// Localized display label for a [UspWanConnectionType]. +/// +/// Single source of truth for the connection-type label so every view renders +/// the same text and a new enum value only has to be handled once. All labels +/// (including PPTP / L2TP) resolve through the l10n keys rather than hardcoded +/// strings. +extension UspWanConnectionTypeLabel on UspWanConnectionType { + String localizedLabel(BuildContext context) { + final l = loc(context); + return switch (this) { + UspWanConnectionType.dhcp => l.connectionTypeDhcp, + UspWanConnectionType.staticIp => l.staticIp, + UspWanConnectionType.pppoe => l.connectionTypePppoe, + UspWanConnectionType.pptp => l.connectionTypePptp, + UspWanConnectionType.l2tp => l.connectionTypeL2tp, + UspWanConnectionType.bridge => l.connectionTypeBridge, + }; + } +} diff --git a/lib/page/internet_settings/views/helpers/bridge_redirect_dialog.dart b/lib/page/internet_settings/views/helpers/bridge_redirect_dialog.dart new file mode 100644 index 000000000..ef2d24df5 --- /dev/null +++ b/lib/page/internet_settings/views/helpers/bridge_redirect_dialog.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:privacy_gui/components/shortcuts/dialogs.dart'; +import 'package:privacy_gui/core/utils/assign_ip/assign_ip.dart'; +import 'package:privacy_gui/localization/localization_hook.dart'; +import 'package:ui_kit_library/ui_kit.dart'; + +/// Shows the post-save Bridge Mode dialog: the router is now a transparent +/// bridge and must be reached at `https://.local`. The primary button +/// navigates the browser there (the browser handles DNS/cert/retry — the only +/// reliable cross-origin path for a self-signed `.local` host). +/// +/// [navigate] defaults to the real web redirect and is injectable for tests. +Future showBridgeRedirectDialog( + BuildContext context, { + required String hostName, + void Function(String url) navigate = assignWebLocation, +}) { + final url = 'https://$hostName.local'; + final l = loc(context); + return showSimpleAppDialog( + context, + dismissible: false, + title: l.bridgeRedirectTitle, + content: AppText( + l.bridgeRedirectMessage(url), + variant: AppTextVariant.bodyMedium, + fontWeight: FontWeight.bold, + ), + actions: [ + // No dismiss/close action: once bridge mode is applied, the router is + // unreachable on this origin, so redirecting to https://.local + // is the only valid next step. The dialog is non-dismissible (above). + AppButton.primary( + label: l.bridgeRedirectButton, + onTap: () => navigate(url), + ), + ], + ); +} diff --git a/lib/page/internet_settings/views/sections/usp_ipv4_section.dart b/lib/page/internet_settings/views/sections/usp_ipv4_section.dart index 0dda84ffc..4c39e154d 100644 --- a/lib/page/internet_settings/views/sections/usp_ipv4_section.dart +++ b/lib/page/internet_settings/views/sections/usp_ipv4_section.dart @@ -6,6 +6,7 @@ import 'package:privacy_gui/page/internet_settings/models/usp_internet_settings_ import 'package:privacy_gui/page/internet_settings/models/usp_wan_connection_type.dart'; import 'package:privacy_gui/page/internet_settings/providers/usp_internet_settings_notifier.dart'; import 'package:privacy_gui/page/internet_settings/models/internet_settings_feature_state.dart'; +import 'package:privacy_gui/page/internet_settings/views/components/usp_connection_type_label.dart'; import 'package:privacy_gui/page/internet_settings/views/components/usp_section_card.dart'; import 'package:ui_kit_library/ui_kit.dart'; @@ -40,6 +41,7 @@ class _UspIpv4SectionState extends ConsumerState { late TextEditingController _pppUsernameController; late TextEditingController _pppPasswordController; late TextEditingController _pppServiceNameController; + late TextEditingController _serverAddressController; late TextEditingController _vlanIdController; late TextEditingController _idleTimeController; @@ -61,6 +63,7 @@ class _UspIpv4SectionState extends ConsumerState { _pppPasswordController = TextEditingController(text: form.pppPassword); _pppServiceNameController = TextEditingController(text: form.pppoeServiceName); + _serverAddressController = TextEditingController(text: form.serverAddress); _vlanIdController = TextEditingController(text: form.vlanId.toString()); _idleTimeController = TextEditingController(text: form.idleDisconnectTime.toString()); @@ -85,6 +88,7 @@ class _UspIpv4SectionState extends ConsumerState { _syncIfDifferent(_pppUsernameController, form.pppUsername); _syncIfDifferent(_pppPasswordController, form.pppPassword); _syncIfDifferent(_pppServiceNameController, form.pppoeServiceName); + _syncIfDifferent(_serverAddressController, form.serverAddress); _syncIfDifferent(_vlanIdController, form.vlanId.toString()); _syncIfDifferent(_idleTimeController, form.idleDisconnectTime.toString()); } @@ -106,6 +110,7 @@ class _UspIpv4SectionState extends ConsumerState { _pppUsernameController.dispose(); _pppPasswordController.dispose(); _pppServiceNameController.dispose(); + _serverAddressController.dispose(); _vlanIdController.dispose(); _idleTimeController.dispose(); super.dispose(); @@ -128,7 +133,7 @@ class _UspIpv4SectionState extends ConsumerState { label: loc(context).connectionType, items: UspWanConnectionType.values, value: form.connectionType, - itemAsString: (type) => _connectionTypeLabel(context, type), + itemAsString: (type) => type.localizedLabel(context), onChanged: (type) { if (type != null) { ref @@ -140,7 +145,7 @@ class _UspIpv4SectionState extends ConsumerState { else UspInfoRow( label: loc(context).connectionType, - value: _connectionTypeLabel(context, form.connectionType), + value: form.connectionType.localizedLabel(context), ), AppGap.md(), // Conditional fields based on connection type @@ -161,8 +166,11 @@ class _UspIpv4SectionState extends ConsumerState { return _buildStaticIpFields(form, isEditing); case UspWanConnectionType.pppoe: return _buildPppoeFields(form, isEditing); + case UspWanConnectionType.pptp: + case UspWanConnectionType.l2tp: + return _buildTunnelFields(form, isEditing); case UspWanConnectionType.bridge: - return _buildBridgeFields(); + return _buildBridgeFields(isEditing); } } @@ -315,21 +323,109 @@ class _UspIpv4SectionState extends ConsumerState { ]; } - List _buildBridgeFields() { + List _buildTunnelFields( + UspInternetSettingsForm form, bool isEditing) { + final l = loc(context); + final typeName = + form.connectionType == UspWanConnectionType.pptp ? 'PPTP' : 'L2TP'; + if (!isEditing) { + return [ + UspInfoRow(label: '$typeName Server', value: form.serverAddress), + UspInfoRow(label: l.username, value: form.pppUsername), + UspInfoRow(label: l.connectionMode, value: form.connectionTrigger), + UspInfoRow(label: l.pppStatus, value: widget.state.pppConnectionStatus), + if (form.vlanEnabled) + UspInfoRow(label: l.vlanIdOptional, value: '${form.vlanId}'), + ]; + } return [ + AppTextFormField( + controller: _serverAddressController, + label: '$typeName Server', + hintText: 'vpn.example.com', + onChanged: (v) => _updateField((f) => f.copyWith(serverAddress: v)), + ), + AppGap.md(), + AppTextFormField( + controller: _pppUsernameController, + label: l.username, + onChanged: (v) => _updateField((f) => f.copyWith(pppUsername: v)), + ), + AppGap.md(), + AppTextFormField( + controller: _pppPasswordController, + label: l.password, + obscureText: true, + onChanged: (v) => _updateField((f) => f.copyWith(pppPassword: v)), + ), + AppGap.lg(), + // Connection mode + AppText.labelLarge(l.connectionMode), + AppGap.sm(), + AppRadioList( + items: [ + AppRadioListItem(title: l.keepAlive, value: 'AlwaysOn'), + AppRadioListItem(title: l.connectOnDemand, value: 'OnDemand'), + ], + selected: form.connectionTrigger, + onChanged: (_, v) { + if (v != null) { + _updateField((f) => f.copyWith(connectionTrigger: v)); + } + }, + ), + AppGap.md(), + if (form.connectionTrigger == 'OnDemand') ...[ + AppTextFormField( + controller: _idleTimeController, + label: l.maxIdleTime, + keyboardType: TextInputType.number, + onChanged: (v) => _updateField( + (f) => f.copyWith(idleDisconnectTime: int.tryParse(v) ?? 0)), + ), + AppGap.md(), + ], + // VLAN AppGap.md(), - AppText.bodyMedium(loc(context).bridgeModeWarning), + Row( + children: [ + AppText.labelLarge(l.vlanTagging), + const Spacer(), + AppSwitch( + value: form.vlanEnabled, + onChanged: (v) => _updateField((f) => f.copyWith(vlanEnabled: v)), + ), + ], + ), + if (form.vlanEnabled) ...[ + AppGap.md(), + AppTextFormField( + controller: _vlanIdController, + label: l.vlanIdOptional, + keyboardType: TextInputType.number, + onChanged: (v) => + _updateField((f) => f.copyWith(vlanId: int.tryParse(v) ?? 0)), + ), + ], ]; } - String _connectionTypeLabel(BuildContext context, UspWanConnectionType type) { + List _buildBridgeFields(bool isEditing) { final l = loc(context); - return switch (type) { - UspWanConnectionType.dhcp => l.connectionTypeDhcp, - UspWanConnectionType.staticIp => l.staticIp, - UspWanConnectionType.pppoe => l.connectionTypePppoe, - UspWanConnectionType.bridge => l.connectionTypeBridge, - }; + final hostName = widget.state.readOnlyInfo.hostName; + final showHint = isEditing && hostName.isNotEmpty; + return [ + AppGap.md(), + AppText.bodyMedium(l.bridgeModeWarning), + if (showHint) ...[ + AppGap.md(), + AppText( + l.bridgeReconnectHint('https://$hostName.local'), + variant: AppTextVariant.bodyMedium, + fontWeight: FontWeight.bold, + ), + ], + ]; } void _updateField( diff --git a/lib/page/internet_settings/views/sections/usp_optional_section.dart b/lib/page/internet_settings/views/sections/usp_optional_section.dart index 6e060a329..d47a055fb 100644 --- a/lib/page/internet_settings/views/sections/usp_optional_section.dart +++ b/lib/page/internet_settings/views/sections/usp_optional_section.dart @@ -53,16 +53,10 @@ class _UspOptionalSectionState extends ConsumerState { super.dispose(); } - int get _mtuMin => 576; - int get _mtuMax { - final type = widget.state.edited.connectionType; - // MTU max varies by connection type due to protocol overhead - return switch (type) { - UspWanConnectionType.pppoe => 1492, // 1500 - 8 (PPP header) - // Future: pptp/l2tp => 1460 (tunnel overhead) - _ => 1500, // Ethernet standard (DHCP, Static, Bridge) - }; - } + // MTU range is owned by [UspWanConnectionType] (single source of truth, + // shared with the notifier's clamp on type switch). + int get _mtuMin => widget.state.edited.connectionType.mtuMin; + int get _mtuMax => widget.state.edited.connectionType.mtuMax; String? _getMtuError(BuildContext context, int mtu) { if (mtu < _mtuMin) return loc(context).mtuMinError(_mtuMin); diff --git a/lib/page/internet_settings/views/usp_internet_settings_view.dart b/lib/page/internet_settings/views/usp_internet_settings_view.dart index 4c17ef6e5..aaa0b4501 100644 --- a/lib/page/internet_settings/views/usp_internet_settings_view.dart +++ b/lib/page/internet_settings/views/usp_internet_settings_view.dart @@ -12,6 +12,8 @@ import 'package:privacy_gui/page/internet_settings/providers/usp_internet_settin import 'package:privacy_gui/page/internet_settings/views/components/usp_connection_status_banner.dart'; import 'package:privacy_gui/page/internet_settings/views/sections/usp_ipv4_section.dart'; import 'package:privacy_gui/page/internet_settings/views/sections/usp_ipv6_section.dart'; +import 'package:privacy_gui/page/internet_settings/models/usp_wan_connection_type.dart'; +import 'package:privacy_gui/page/internet_settings/views/helpers/bridge_redirect_dialog.dart'; import 'package:privacy_gui/page/internet_settings/views/sections/usp_optional_section.dart'; import 'package:privacy_gui/page/internet_settings/views/sections/usp_renew_section.dart'; import 'package:ui_kit_library/ui_kit.dart'; @@ -47,6 +49,7 @@ class UspInternetSettingsView extends ConsumerWidget { if (state.status.error != null) { return ServiceErrorView( error: state.status.error, + title: loc(context).failedToLoadSettings, onRetry: () => ref.read(uspInternetSettingsProvider.notifier).fetch(), ); @@ -183,12 +186,26 @@ class UspInternetSettingsView extends ConsumerWidget { } Future _save(BuildContext context, WidgetRef ref) async { + final notifier = ref.read(uspInternetSettingsProvider.notifier); + final preSave = ref.read(uspInternetSettingsProvider); + // Read the submitted transition BEFORE saving: `original` is the baseline + // type, `edited` is what the user just chose. This is the unambiguous + // intent signal, independent of post-save device timing. + final previousType = preSave.original.connectionType; + final submittedType = preSave.edited.connectionType; + final hostName = preSave.readOnlyInfo.hostName; + try { - await doSomethingWithSpinner( - context, - ref.read(uspInternetSettingsProvider.notifier).save(), - ); - if (context.mounted) { + await doSomethingWithSpinner(context, notifier.save()); + if (!context.mounted) return; + + if (shouldRedirectToBridge( + previousType: previousType, + newType: submittedType, + hostName: hostName, + )) { + await showBridgeRedirectDialog(context, hostName: hostName); + } else { showSuccessSnackBar(context, loc(context).changesSaved); } } catch (e) { @@ -198,3 +215,15 @@ class UspInternetSettingsView extends ConsumerWidget { } } } + +/// Whether a save transition should trigger the Bridge Mode redirect dialog: +/// the WAN entered bridge (was not bridge, now is) and a hostname is known. +bool shouldRedirectToBridge({ + required UspWanConnectionType previousType, + required UspWanConnectionType newType, + required String hostName, +}) { + return previousType != UspWanConnectionType.bridge && + newType == UspWanConnectionType.bridge && + hostName.isNotEmpty; +} diff --git a/lib/page/ipv6_port_service/views/usp_ipv6_port_service_view.dart b/lib/page/ipv6_port_service/views/usp_ipv6_port_service_view.dart index 672244cb5..25db9214e 100644 --- a/lib/page/ipv6_port_service/views/usp_ipv6_port_service_view.dart +++ b/lib/page/ipv6_port_service/views/usp_ipv6_port_service_view.dart @@ -46,6 +46,7 @@ class UspIpv6PortServiceView extends ConsumerWidget { if (status.error != null) { return ServiceErrorView( error: status.error, + title: loc(context).failedToLoadSettings, onRetry: () => ref .read(uspIpv6PortServiceProvider.notifier) .fetch(forceRemote: true), @@ -191,7 +192,7 @@ class UspIpv6PortServiceView extends ConsumerWidget { List _buildIpv6DeviceOptions(WidgetRef ref) { final devices = - ref.read(devicesDataProvider).valueOrNull?.deviceModels ?? []; + ref.read(devicesDataProvider).valueOrNull?.clientDevices ?? []; return devices .expand((d) => d.ipv6Addresses.map((addr) => AppAutoCompleteOption( label: d.displayName, diff --git a/lib/page/local_network/cards/usp_dhcp_reservations_card.dart b/lib/page/local_network/cards/usp_dhcp_reservations_card.dart index bb28221aa..30212060a 100644 --- a/lib/page/local_network/cards/usp_dhcp_reservations_card.dart +++ b/lib/page/local_network/cards/usp_dhcp_reservations_card.dart @@ -12,7 +12,7 @@ import 'package:privacy_gui/page/_shared/components/layout_blocks.dart'; import 'package:privacy_gui/page/_shared/components/usp_mutation_helper.dart'; import 'package:privacy_gui/page/_shared/components/card_skeleton.dart'; import 'package:privacy_gui/page/_shared/components/dashboard_card_template.dart'; -import 'package:privacy_gui/page/dashboard/views/dialogs/dhcp_reservation_dialog.dart'; +import 'package:privacy_gui/page/dhcp/views/dialogs/dhcp_reservation_edit_dialog.dart'; import 'package:ui_kit_library/ui_kit.dart'; class UspDhcpReservationsCard extends ConsumerWidget { @@ -24,7 +24,8 @@ class UspDhcpReservationsCard extends ConsumerWidget { if (dhcpData == null) return const CardSkeleton.list(rows: 3); final reservations = dhcpData.reservationModels; final clients = dhcpData.clientModels; - final activeClients = clients.where((c) => c.active).toList(); + // Dashboard shows only online clients (based on Hosts.Active, not DHCP lease). + final onlineClients = clients.where((c) => c.isOnline == true).toList(); final isLoading = ref.watch(uspMutationLoadingProvider) == 'dhcp'; return DashboardCardTemplate.multiSection( @@ -34,7 +35,7 @@ class UspDhcpReservationsCard extends ConsumerWidget { onTap: isLoading ? null : () => _showAddDhcpDialog(context, ref), ), detailRoute: RouteNamed.uspDhcpDetail, - itemCount: reservations.length + activeClients.length, + itemCount: reservations.length + onlineClients.length, sections: [ CardSection( title: loc(context).reservations, @@ -52,14 +53,14 @@ class UspDhcpReservationsCard extends ConsumerWidget { ), CardSection( title: loc(context).activeLeases, - titleBadge: AppText.labelMedium('${activeClients.length}'), - isEmpty: clients.isEmpty, + titleBadge: AppText.labelMedium('${onlineClients.length}'), + isEmpty: onlineClients.isEmpty, emptyMessage: 'No DHCP clients', content: Column( children: [ - for (var i = 0; i < clients.length; i++) ...[ - _buildClientRow(context, clients[i]), - if (i < clients.length - 1) AppGap.sm(), + for (var i = 0; i < onlineClients.length; i++) ...[ + _buildClientRow(context, onlineClients[i]), + if (i < onlineClients.length - 1) AppGap.sm(), ], ], ), @@ -70,39 +71,26 @@ class UspDhcpReservationsCard extends ConsumerWidget { Widget _buildReservationRow(BuildContext context, WidgetRef ref, DhcpReservationUIModel reservation, bool isLoading) { - final colorScheme = Theme.of(context).colorScheme; - - return LayoutBlock( - child: Row( - children: [ - AppSwitch( - value: reservation.enable, - scale: 0.8, - onChanged: isLoading - ? null - : (value) => performUspMutation( - context, - ref, - loadingKey: 'dhcp', - mutation: () => ref - .read(uspDhcpReservationsProvider.notifier) - .immediateToggle(reservation.instancePath!, value), - ), - ), - AppGap.sm(), - Expanded(child: AppText.bodyMedium(reservation.mac)), - AppText.bodySmall( - reservation.ip, - color: colorScheme.onSurfaceVariant, - ), - AppGap.sm(), - AppIconButton( - icon: AppIcon.font(Icons.delete_outline, size: 18), - onTap: isLoading - ? null - : () => _confirmDeleteDhcp(context, ref, reservation), - ), - ], + return ToggleRow( + value: reservation.enable, + isLoading: isLoading, + onChanged: isLoading || reservation.instancePath == null + ? null + : (value) => performUspMutation( + context, + ref, + loadingKey: 'dhcp', + mutation: () => ref + .read(uspDhcpReservationsProvider.notifier) + .immediateToggle(reservation.instancePath!, value), + ), + title: reservation.mac, + subtitle: reservation.ip, + trailing: AppIconButton( + icon: AppIcon.font(Icons.delete_outline, size: 18), + onTap: isLoading || reservation.instancePath == null + ? null + : () => _confirmDeleteDhcp(context, ref, reservation), ), ); } @@ -112,33 +100,22 @@ class UspDhcpReservationsCard extends ConsumerWidget { final appColors = Theme.of(context).extension(); final lease = client.leaseTimeFormatted; - return LayoutBlock( - child: Row( + return DeviceRow( + icon: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: client.isOnline == true + ? (appColors?.semanticSuccess ?? Colors.green) + : colorScheme.outline, + ), + ), + title: client.displayName, + subtitle: client.hostName.isNotEmpty ? client.mac : null, + trailing: Row( + mainAxisSize: MainAxisSize.min, children: [ - Container( - width: 8, - height: 8, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: client.active - ? (appColors?.semanticSuccess ?? Colors.green) - : colorScheme.outline, - ), - ), - AppGap.sm(), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AppText.bodyMedium(client.displayName), - if (client.hostName.isNotEmpty) - AppText.bodySmall( - client.mac, - color: colorScheme.onSurfaceVariant, - ), - ], - ), - ), AppText.bodySmall(client.ip, color: colorScheme.onSurfaceVariant), if (lease.isNotEmpty) ...[ AppGap.md(), @@ -150,9 +127,13 @@ class UspDhcpReservationsCard extends ConsumerWidget { } Future _showAddDhcpDialog(BuildContext context, WidgetRef ref) async { + final options = _buildDeviceOptions(ref); final result = await showAppDialog<({String mac, String ip, bool enable})>( context: context, - builder: (_) => const DhcpReservationDialog(), + builder: (_) => DhcpReservationEditDialog( + macDeviceOptions: options.mac, + ipDeviceOptions: options.ip, + ), ); if (result == null || !context.mounted) return; await performUspMutation( @@ -165,10 +146,35 @@ class UspDhcpReservationsCard extends ConsumerWidget { ip: result.ip, enable: result.enable, ), - successMessage: 'Reservation added', + successMessage: loc(context).reservationAdded, ); } + ({List mac, List ip}) + _buildDeviceOptions(WidgetRef ref) { + final devices = + ref.read(uspDhcpReservationsProvider.notifier).deviceOptions(); + final macOptions = devices + .where((d) => d.mac.isNotEmpty) + .map((d) => AppAutoCompleteOption( + label: d.name, + value: d.mac, + subtitle: d.ip, + isActive: d.isActive, + )) + .toList(); + final ipOptions = devices + .where((d) => d.ip.isNotEmpty) + .map((d) => AppAutoCompleteOption( + label: d.name, + value: d.ip, + subtitle: d.mac, + isActive: d.isActive, + )) + .toList(); + return (mac: macOptions, ip: ipOptions); + } + Future _confirmDeleteDhcp(BuildContext context, WidgetRef ref, DhcpReservationUIModel reservation) async { final confirmed = await showSimpleAppDialog( @@ -195,7 +201,7 @@ class UspDhcpReservationsCard extends ConsumerWidget { mutation: () => ref .read(uspDhcpReservationsProvider.notifier) .immediateDelete(reservation.instancePath!), - successMessage: 'Reservation deleted', + successMessage: loc(context).reservationDeleted, ); } } diff --git a/lib/page/local_network/cards/usp_ethernet_ports_card.dart b/lib/page/local_network/cards/usp_ethernet_ports_card.dart index 30c0cceaa..5b61d0a2f 100644 --- a/lib/page/local_network/cards/usp_ethernet_ports_card.dart +++ b/lib/page/local_network/cards/usp_ethernet_ports_card.dart @@ -7,7 +7,6 @@ import 'package:privacy_gui/page/local_network/providers/ethernet_data_provider. import 'package:privacy_gui/page/_shared/components/card_skeleton.dart'; import 'package:privacy_gui/page/_shared/components/dashboard_card_template.dart'; import 'package:privacy_gui/page/dashboard/views/dialogs/ethernet_port_detail_dialog.dart'; -import 'package:privacy_gui/route/constants.dart'; import 'package:ui_kit_library/ui_kit.dart'; class UspEthernetPortsCard extends ConsumerWidget { @@ -30,7 +29,6 @@ class UspEthernetPortsCard extends ConsumerWidget { return DashboardCardTemplate( title: loc(context).ethernetPorts, - detailRoute: RouteNamed.uspLocalNetwork, content: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/lib/page/local_network/cards/usp_lan_info_card.dart b/lib/page/local_network/cards/usp_lan_info_card.dart index eb0b57aaa..4f803345d 100644 --- a/lib/page/local_network/cards/usp_lan_info_card.dart +++ b/lib/page/local_network/cards/usp_lan_info_card.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacy_gui/core/utils/ipv6_address.dart'; import 'package:privacy_gui/localization/localization_hook.dart'; import 'package:privacy_gui/page/_shared/models/lan_info_ui_model.dart'; import 'package:privacy_gui/page/_shared/components/layout_blocks.dart'; @@ -106,6 +107,13 @@ class UspLanInfoCard extends ConsumerWidget { label: 'IPv6', value: info.ipv6Addresses.first, copyable: true, + // The representative address prefers global unicast; when + // only a link-local (fe80::/10) address exists it is still + // shown, tagged with a scope badge rather than hidden. + // See #1129. + labelTrailing: isLinkLocalIpv6(info.ipv6Addresses.first) + ? const Ipv6ScopeBadge() + : null, ) else if (info.ipv6Enabled) InfoGridItem(label: 'IPv6', value: 'Enabled'), diff --git a/lib/page/local_network/models/local_network_feature_state.dart b/lib/page/local_network/models/local_network_feature_state.dart index 8e2e272ee..ec69d0024 100644 --- a/lib/page/local_network/models/local_network_feature_state.dart +++ b/lib/page/local_network/models/local_network_feature_state.dart @@ -27,6 +27,14 @@ class LocalNetworkFeatureState settings.original.model.ipAddress != settings.current.model.ipAddress || settings.original.model.subnetMask != settings.current.model.subnetMask; + /// True when the router IP address specifically changed. This is the trigger + /// for the post-save redirect + SSE disconnect: only an IP change makes the + /// old origin unreachable and drops the connection. A subnet-mask-only change + /// keeps the same IP, so the current connection survives and normal save + /// (await response + re-fetch) applies. + bool get hasIpAddressChange => + settings.original.model.ipAddress != settings.current.model.ipAddress; + @override LocalNetworkFeatureState copyWith({ Preservable? settings, diff --git a/lib/page/local_network/providers/dhcp_data_provider.dart b/lib/page/local_network/providers/dhcp_data_provider.dart index 06c724b41..2a14f1aa4 100644 --- a/lib/page/local_network/providers/dhcp_data_provider.dart +++ b/lib/page/local_network/providers/dhcp_data_provider.dart @@ -1,9 +1,10 @@ import 'dart:async'; +import 'package:collection/collection.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:privacy_gui/core/utils/logger.dart'; import 'package:privacy_gui/core/usp/providers/sse_invalidation_provider.dart'; +import 'package:privacy_gui/framework/diagnostic_loggable.dart'; import 'package:privacy_gui/page/_shared/models/dhcp_client_ui_model.dart'; import 'package:privacy_gui/page/_shared/models/dhcp_reservation_ui_model.dart'; import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; @@ -11,7 +12,7 @@ import 'package:privacy_gui/page/local_network/services/usp_dhcp_data_service.da // ── Data Model ── -class DhcpData extends Equatable { +class DhcpData extends Equatable with DiagnosticLoggable { final List clientModels; final List reservationModels; @@ -21,10 +22,13 @@ class DhcpData extends Equatable { }); @override - List get props => [ - clientModels.length, - reservationModels.length, - ]; + String get diagnosticName => 'DhcpData'; + + @override + Map get namedProps => { + 'clientModels': clientModels, + 'reservationModels': reservationModels, + }; } // ── Provider ── @@ -46,6 +50,25 @@ class DhcpDataNotifier extends AsyncNotifier { _debouncedInvalidate(); } }); + + // Devices listener: device online status changes affect DHCP client + // isOnline enrichment. Only re-fetch when the online-status map actually + // changed — DevicesData emits on any device field change (RSSI, band, + // SSID), so a naive listener would trigger needless DHCP re-fetches. + ref.listen(devicesDataProvider, (prev, next) { + if (!next.hasValue || !state.hasValue) return; + final prevOnline = { + for (final d in prev?.valueOrNull?.clientDevices ?? []) + d.mac: d.isActive + }; + final nextOnline = { + for (final d in next.value!.clientDevices) d.mac: d.isActive + }; + if (!const MapEquality().equals(prevOnline, nextOnline)) { + _debouncedInvalidate(); + } + }); + ref.onDispose(() => _debounce?.cancel()); return _fetch(); } @@ -53,15 +76,19 @@ class DhcpDataNotifier extends AsyncNotifier { Future _fetch() async { final svc = ref.read(uspDhcpDataServiceProvider); - // Hostname enrichment: read pre-computed map from devices provider. + // Enrichment: read pre-computed maps from devices provider. final devicesData = ref.read(devicesDataProvider).valueOrNull; final hostNameByMac = devicesData?.hostNameByMac ?? const {}; - - final result = await svc.fetch(hostNameByMac: hostNameByMac); - - logger.d('[USP][DhcpData]: Fetched — ' - 'clients: ${result.clientModels.length}, ' - 'reservations: ${result.reservationModels.length}'); + // Compute isOnlineByMac inline from clientDevices (mesh nodes never hold + // DHCP leases, so their MACs cannot appear in DHCP client models). + final isOnlineByMac = { + for (final d in devicesData?.clientDevices ?? []) d.mac: d.isActive, + }; + + final result = await svc.fetch( + hostNameByMac: hostNameByMac, + isOnlineByMac: isOnlineByMac, + ); return DhcpData( clientModels: result.clientModels, diff --git a/lib/page/local_network/providers/ethernet_data_provider.dart b/lib/page/local_network/providers/ethernet_data_provider.dart index bca54c8ef..23b9b179b 100644 --- a/lib/page/local_network/providers/ethernet_data_provider.dart +++ b/lib/page/local_network/providers/ethernet_data_provider.dart @@ -1,7 +1,7 @@ import 'package:equatable/equatable.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:privacy_gui/core/utils/logger.dart'; import 'package:privacy_gui/core/usp/providers/sse_invalidation_provider.dart'; +import 'package:privacy_gui/framework/diagnostic_loggable.dart'; import 'package:privacy_gui/page/_shared/models/ethernet_port_ui_model.dart'; import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; import 'package:privacy_gui/page/local_network/services/usp_ethernet_data_service.dart'; @@ -16,7 +16,7 @@ final ethernetDataProvider = ); /// Aggregated Ethernet data: presentation-layer port models. -class EthernetData extends Equatable { +class EthernetData extends Equatable with DiagnosticLoggable { final List ethernetPortModels; const EthernetData({ @@ -32,7 +32,12 @@ class EthernetData extends Equatable { } @override - List get props => [ethernetPortModels]; + String get diagnosticName => 'EthernetData'; + + @override + Map get namedProps => { + 'ethernetPortModels': ethernetPortModels, + }; } class EthernetDataNotifier extends AsyncNotifier { @@ -41,7 +46,6 @@ class EthernetDataNotifier extends AsyncNotifier { // SSE listener: Ethernet interface status changes (link up/down) ref.listen(sseInvalidationProvider, (_, next) { if (next.value == InvalidationDomain.ethernetInterfaces) { - logger.d('[USP][Ethernet]: SSE invalidation received, refreshing'); ref.invalidateSelf(); } }); @@ -60,12 +64,9 @@ class EthernetDataNotifier extends AsyncNotifier { Future _fetch() async { final svc = ref.read(uspEthernetDataServiceProvider); final devicesData = ref.read(devicesDataProvider).valueOrNull; - final deviceModels = devicesData?.deviceModels ?? []; - - final result = await svc.fetch(deviceModels: deviceModels); + final devices = devicesData?.clientDevices ?? []; - logger.d('[USP][Ethernet]: Fetch complete — ' - '${result.portModels.length} port models'); + final result = await svc.fetch(deviceModels: devices); return EthernetData(ethernetPortModels: result.portModels); } diff --git a/lib/page/local_network/providers/lan_data_provider.dart b/lib/page/local_network/providers/lan_data_provider.dart index 00f8a84ef..0c7dc88d1 100644 --- a/lib/page/local_network/providers/lan_data_provider.dart +++ b/lib/page/local_network/providers/lan_data_provider.dart @@ -1,12 +1,12 @@ import 'package:equatable/equatable.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:privacy_gui/core/utils/logger.dart'; +import 'package:privacy_gui/framework/diagnostic_loggable.dart'; import 'package:privacy_gui/page/_shared/models/lan_info_ui_model.dart'; import 'package:privacy_gui/page/local_network/services/usp_lan_data_service.dart'; // ── Data Model ── -class LanData extends Equatable { +class LanData extends Equatable with DiagnosticLoggable { final LanInfoUIModel model; const LanData({required this.model}); @@ -21,7 +21,10 @@ class LanData extends Equatable { ); @override - List get props => [model]; + String get diagnosticName => 'LanData'; + + @override + Map get namedProps => {'model': model}; } // ── Provider ── @@ -42,8 +45,6 @@ class LanDataNotifier extends AsyncNotifier { final svc = ref.read(uspLanDataServiceProvider); final model = await svc.fetch(); - logger.d('[USP][LanData]: Fetched — ip=${model.ipAddress}, ' - 'dhcp=${model.dhcpEnabled}, ipv6=${model.ipv6Enabled}'); return LanData(model: model); } } diff --git a/lib/page/local_network/providers/usp_local_network_notifier.dart b/lib/page/local_network/providers/usp_local_network_notifier.dart index a66cf5590..cd234a298 100644 --- a/lib/page/local_network/providers/usp_local_network_notifier.dart +++ b/lib/page/local_network/providers/usp_local_network_notifier.dart @@ -1,6 +1,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/core/utils/logger.dart'; +import 'package:privacy_gui/core/usp/providers/sse_providers.dart'; import 'package:privacy_gui/core/usp/providers/usp_mutation_lock.dart'; import 'package:privacy_gui/framework/preservable_contract.dart'; import 'package:privacy_gui/framework/preservable_notifier_mixin.dart'; @@ -96,6 +97,47 @@ class UspLocalNetworkNotifier } } + // --------------------------------------------------------------------------- + // save — override the mixin's default (performSave -> markAsSaved -> refetch) + // to special-case a router IP / subnet change, which makes the router + // unreachable on this origin. + // --------------------------------------------------------------------------- + + @override + Future save() async { + // Detect the IP change BEFORE saving: markAsSaved() below collapses + // original into current, so this must be read up front. Only an IP address + // change (not a mask-only change) drops the current connection, so that is + // the trigger for the redirect + SSE disconnect. + final ipChanged = state.hasIpAddressChange; + + await performSave(); + markAsSaved(); + + if (ipChanged) { + // Changing the router LAN IP makes it unreachable on this origin + // (the old address stops answering). Two effects are handled here, both + // specific to this transition: + // + // 1. Drop SSE intentionally. disconnect() sets _intentionalDisconnect, + // which stops the reconnect backoff and suppresses onReconnectFailed, + // so the app-level recovery flow (2 reconnect failures -> + // waitingForRecovery) never fires on top of the LAN IP redirect + // dialog. + // 2. Skip the post-save re-fetch. The SET already succeeded; the device + // is now gone from this origin, so fetch(forceRemote: true) would only + // time out and surface a spurious error for an operation that actually + // succeeded. The redirect dialog is the only valid next step. + await ref.read(sseManagerProvider)?.disconnect(); + return state; + } + + // No IP change (e.g. only DHCP or subnet-mask fields changed): the + // connection is still alive, so re-fetch so the dashboard card updates too. + ref.invalidate(lanDataProvider); + return fetch(forceRemote: true); + } + // --------------------------------------------------------------------------- // performSave — required by PreservableAutoDisposeNotifierMixin // --------------------------------------------------------------------------- @@ -114,9 +156,6 @@ class UspLocalNetworkNotifier await _svc.save(original: o, pending: p); logger.d('[USP][Network][LAN]: Saved'); }); - - // Force data provider to re-fetch so dashboard card updates too. - ref.invalidate(lanDataProvider); } on ServiceError catch (e) { logger.e('[USP][Network][LAN]: Save failed', error: e); rethrow; @@ -143,10 +182,13 @@ class UspLocalNetworkNotifier // UI Mutation (synchronous — no network call) // --------------------------------------------------------------------------- - /// Update a single setting + trigger cascade validation. + /// Update a single setting WITHOUT triggering validation. + /// + /// Use this for onChange handlers to avoid TextField unfocus on Web. + /// Call [validate] separately on unfocus. /// - /// When router IP changes, locked-prefix octets of pool IPs are - /// automatically synced so the user doesn't have to retype them. + /// When router IP OR subnet mask changes, locked-prefix octets of pool IPs + /// are automatically synced so the user doesn't have to retype them. void updateSetting( LocalNetworkUIModel Function(LocalNetworkUIModel) updater) { final current = state.settings.current; @@ -157,9 +199,15 @@ class UspLocalNetworkNotifier newModel = _svc.applyDhcpDefaults(newModel); } - // Auto-sync pool prefix when router IP changes - if (newModel.ipAddress != current.model.ipAddress && - newModel.subnetMask.isNotEmpty) { + // Auto-sync pool prefix when the router IP OR the subnet mask changes. + // The mask must be included: it determines lockedOctetCount, which the UI + // uses to lock pool prefix octets read-only. If only the IP triggered the + // sync, changing the mask (e.g. /16 → /24) would widen the locked range + // without updating the pool, leaving pool octets that are both out-of-subnet + // (validation error) AND read-only (uneditable) — a dead end. + final ipChanged = newModel.ipAddress != current.model.ipAddress; + final maskChanged = newModel.subnetMask != current.model.subnetMask; + if ((ipChanged || maskChanged) && newModel.subnetMask.isNotEmpty) { final locked = _svc.lockedOctetCount(newModel.subnetMask); if (locked > 0) { newModel = newModel.copyWith( @@ -171,16 +219,24 @@ class UspLocalNetworkNotifier } } - final errors = _svc.validateAll(newModel); - state = state.copyWith( settings: state.settings.update( current.copyWith(model: newModel), ), status: state.status.copyWith( - validationErrors: errors, lockedOctetCount: _svc.lockedOctetCount(newModel.subnetMask), ), ); } + + /// Trigger validation on current settings. + /// + /// Call this on TextField unfocus to avoid unfocus issues on Web. + void validate() { + final current = state.settings.current.model; + final errors = _svc.validateAll(current); + state = state.copyWith( + status: state.status.copyWith(validationErrors: errors), + ); + } } diff --git a/lib/page/local_network/services/usp_dhcp_data_service.dart b/lib/page/local_network/services/usp_dhcp_data_service.dart index d084dbaec..225531092 100644 --- a/lib/page/local_network/services/usp_dhcp_data_service.dart +++ b/lib/page/local_network/services/usp_dhcp_data_service.dart @@ -51,9 +51,10 @@ class UspDhcpDataService { UspDhcpDataService(this._usp); /// Fetches DHCP clients + reservations in parallel, applies hostname - /// enrichment, and returns UI models. + /// and online status enrichment, and returns UI models. Future fetch({ required Map hostNameByMac, + required Map isOnlineByMac, }) async { try { final results = await Future.wait([ @@ -64,15 +65,17 @@ class UspDhcpDataService { final clients = results[0] as DhcpClients; final reservations = results[1] as DhcpReservations; - final clientModels = clients.items - .map((c) => DhcpClientUIModel( - mac: c.chaddr, - ip: c.ipAddress, - active: c.active, - hostName: hostNameByMac[c.chaddr.trim().toUpperCase()] ?? '', - leaseExpiry: c.leaseTimeRemaining, - )) - .toList(); + final clientModels = clients.items.map((c) { + final normalizedMac = c.chaddr.trim().toUpperCase(); + return DhcpClientUIModel( + mac: c.chaddr, + ip: c.ipAddress, + leaseActive: c.active, + isOnline: isOnlineByMac[normalizedMac], + hostName: hostNameByMac[normalizedMac] ?? '', + leaseExpiry: c.leaseTimeRemaining, + ); + }).toList(); final reservationModels = reservations.items .map((r) => DhcpReservationUIModel( diff --git a/lib/page/local_network/services/usp_ethernet_data_service.dart b/lib/page/local_network/services/usp_ethernet_data_service.dart index 486359e25..ddea07ff6 100644 --- a/lib/page/local_network/services/usp_ethernet_data_service.dart +++ b/lib/page/local_network/services/usp_ethernet_data_service.dart @@ -1,11 +1,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/core/utils/logger.dart'; +import 'package:privacy_gui/core/utils/tr181_path.dart'; import 'package:privacy_gui/core/usp/errors/usp_error.dart'; import 'package:privacy_gui/generated/ethernet_interfaces.g.dart'; import 'package:privacy_gui/core/usp/providers/usp_client_provider.dart'; import 'package:privacy_gui/core/usp/services/usp_client.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; import 'package:privacy_gui/page/_shared/models/ethernet_port_ui_model.dart'; // --------------------------------------------------------------------------- @@ -49,7 +50,7 @@ class UspEthernetDataService { /// Fetches Ethernet interfaces + bridge port map, builds port UI models. Future fetch({ - required List deviceModels, + required List deviceModels, }) async { final List results; try { @@ -123,17 +124,17 @@ class UspEthernetDataService { /// active wired device is shown as its own LAN port entry. List _buildEthernetPortUIModels({ required EthernetInterfaces ethernetInterfaces, - required List deviceModels, + required List deviceModels, Map bridgePortMap = const {}, }) { final result = []; final bridgeMemberPaths = - bridgePortMap.values.map(_ensureTrailingDot).toSet(); + bridgePortMap.values.map(ensureTrailingDot).toSet(); EthernetInterface? lanAggregate; for (final iface in ethernetInterfaces.items) { - final path = _ensureTrailingDot(iface.instancePath); + final path = ensureTrailingDot(iface.instancePath); if (bridgeMemberPaths.contains(path)) { lanAggregate ??= iface; } else { @@ -156,7 +157,6 @@ class UspEthernetDataService { final wiredConnections = <({String displayName, String mac, String ip})>[]; for (final d in deviceModels) { - if (!d.isClientDevice) continue; // Check primary interface if (d.isActive && !d.isWifi) { wiredConnections.add(( @@ -215,9 +215,4 @@ class UspEthernetDataService { return result; } - - static String _ensureTrailingDot(String path) { - if (path.isEmpty) return path; - return path.endsWith('.') ? path : '$path.'; - } } diff --git a/lib/page/local_network/services/usp_lan_data_service.dart b/lib/page/local_network/services/usp_lan_data_service.dart index 11107d67a..ecb6b28a1 100644 --- a/lib/page/local_network/services/usp_lan_data_service.dart +++ b/lib/page/local_network/services/usp_lan_data_service.dart @@ -1,5 +1,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:privacy_gui/core/errors/service_error.dart'; +import 'package:privacy_gui/core/utils/ipv6_address.dart'; import 'package:privacy_gui/core/utils/logger.dart'; import 'package:privacy_gui/core/usp/errors/usp_error.dart'; import 'package:privacy_gui/core/usp/services/usp_client.dart'; @@ -70,11 +71,18 @@ class UspLanDataService { 'Device.IP.Interface.1.IPv6Address.', ]).timeout(const Duration(seconds: 20)); + // Issue #1129: keep every LAN IPv6 address (including link-local) and let + // the UI mark a link-local (fe80::/10) address with a scope badge rather + // than hiding it. Reorder so a globally routable address is preferred as + // the representative value; when the interface holds only a link-local + // address it is still shown, tagged as link-local. Ordering is shared with + // the WAN path via `preferGlobalIpv6First`. final instances = resp.getInstances('Device.IP.Interface.1.IPv6Address.'); - return instances + final addresses = instances .map((i) => i.getString('IPAddress')) .where((ip) => ip.isNotEmpty) .toList(); + return preferGlobalIpv6First(addresses); } catch (e) { logger.w('[USP][LanData]: IPv6 addresses fetch failed: $e'); return const []; diff --git a/lib/page/local_network/services/usp_local_network_service.dart b/lib/page/local_network/services/usp_local_network_service.dart index 88946d0cc..0fc0c884c 100644 --- a/lib/page/local_network/services/usp_local_network_service.dart +++ b/lib/page/local_network/services/usp_local_network_service.dart @@ -1,6 +1,9 @@ +import 'dart:async'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/core/usp/errors/usp_error.dart'; +import 'package:privacy_gui/core/utils/logger.dart'; import 'package:privacy_gui/generated/lan_network_info.g.dart'; import 'package:privacy_gui/core/usp/providers/usp_client_provider.dart'; import 'package:privacy_gui/core/usp/services/usp_client.dart'; @@ -24,16 +27,30 @@ class UspLocalNetworkService { // ─── CRUD ────────────────────────────────────────────────── + /// Time budget for a SET that changes the router IP. Changing the LAN IP + /// makes the firmware drop the connection carrying the SET response, so the + /// response can never arrive — bound the wait instead of hanging. + static const _ipChangeSetTimeout = Duration(seconds: 4); + /// Save changed LAN settings. Only sends fields that differ from original. + /// + /// When the router IP address changes, the SET is terminal-by-design: the + /// firmware applies the new IP and drops the connection carrying the SET + /// response, so a timeout / transport error on an IP-changing SET is the + /// expected signature of success (the SET was received and applied before the + /// disconnect). Any fault the router actively returns BEFORE the disconnect — + /// a validation/resource/partial/complete failure — still propagates, so a + /// genuine config failure is never hidden. When the IP does not change, the + /// response is awaited normally and every error surfaces. Future save({ required LocalNetworkUIModel original, required LocalNetworkUIModel pending, }) async { + final ipAddressChanged = original.ipAddress != pending.ipAddress; try { - final result = await LanNetworkInfo.update( + final updateFuture = LanNetworkInfo.update( _usp, - ipAddress: - original.ipAddress != pending.ipAddress ? pending.ipAddress : null, + ipAddress: ipAddressChanged ? pending.ipAddress : null, subnetMask: original.subnetMask != pending.subnetMask ? pending.subnetMask : null, @@ -55,30 +72,63 @@ class UspLocalNetworkService { ? joinDnsServers( pending.dnsServer1, pending.dnsServer2, pending.dnsServer3) : null, + allowPartial: true, ); - final parsed = UspResultParser.parseSetResult(result); - switch (parsed) { - case UspSuccess(): - break; - case UspPartialSuccess( - :final errorSummary, - :final successes, - :final failures - ): - throw UspPartialFailureError( - summary: 'Local network update partial failure: $errorSummary', - successPaths: successes.map((s) => s.requestedPath).toList(), - failures: failures, - ); - case UspFailure(:final errorSummary, :final errors): - throw UspCompleteFailureError( - summary: 'Local network update failed: $errorSummary', - failures: errors, - ); - } + + // On an IP change the response rides a connection the firmware is about + // to drop; bound the wait so we don't hang on a reply that can't arrive. + final result = ipAddressChanged + ? await updateFuture.timeout(_ipChangeSetTimeout) + : await updateFuture; + + _handleSetResult(result); + } on TimeoutException { + // No response within the budget: the firmware applied the new IP and + // dropped the connection, so the SET_RESP can never arrive. This is only + // reachable when the IP changed (the timeout is applied only then). + logger.i('[USP][Network][LAN]: IP-change SET timed out after ' + '${_ipChangeSetTimeout.inSeconds}s — treating as success ' + '(firmware dropped the connection applying the new IP)'); } catch (e) { + // A fault the router actively returned (ServiceError from + // _handleSetResult) means the connection was alive and the config was + // rejected → propagate, even on an IP change. if (e is ServiceError) rethrow; - throw mapUspErrorToServiceError(e); + // Transport / connectivity errors are raw (non-ServiceError). On an IP + // change they are the disconnect signature = success; otherwise they are + // a genuine failure. + final mapped = mapUspErrorToServiceError(e); + if (ipAddressChanged && + (mapped is NetworkError || mapped is ConnectivityError)) { + logger.i('[USP][Network][LAN]: IP-change SET hit a transport error ' + '— treating as success (firmware dropped the connection)'); + return; + } + throw mapped; + } + } + + /// Parse a SET result and throw the appropriate [ServiceError] on failure. + void _handleSetResult(Map result) { + final parsed = UspResultParser.parseSetResult(result); + switch (parsed) { + case UspSuccess(): + break; + case UspPartialSuccess( + :final errorSummary, + :final successes, + :final failures + ): + throw UspPartialFailureError( + summary: 'Local network update partial failure: $errorSummary', + successPaths: successes.map((s) => s.requestedPath).toList(), + failures: failures, + ); + case UspFailure(:final errorSummary, :final errors): + throw UspCompleteFailureError( + summary: 'Local network update failed: $errorSummary', + failures: errors, + ); } } diff --git a/lib/page/local_network/views/helpers/lan_ip_redirect_dialog.dart b/lib/page/local_network/views/helpers/lan_ip_redirect_dialog.dart new file mode 100644 index 000000000..e2169ffb4 --- /dev/null +++ b/lib/page/local_network/views/helpers/lan_ip_redirect_dialog.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:privacy_gui/components/shortcuts/dialogs.dart'; +import 'package:privacy_gui/core/utils/assign_ip/assign_ip.dart'; +import 'package:privacy_gui/localization/localization_hook.dart'; +import 'package:ui_kit_library/ui_kit.dart'; + +/// Shows the post-save LAN IP dialog: the router LAN IP just changed, so the +/// old address is no longer reachable on this origin. The primary button +/// navigates the browser to `https://.local`, letting the browser +/// handle DNS (mDNS) / cert / retry — the only reliable path to reach the +/// router at its new address once the client picks up a new DHCP lease. +/// +/// [navigate] defaults to the real web redirect and is injectable for tests. +Future showLanIpRedirectDialog( + BuildContext context, { + required String hostName, + void Function(String url) navigate = assignWebLocation, +}) { + final url = 'https://$hostName.local'; + final l = loc(context); + return showSimpleAppDialog( + context, + dismissible: false, + title: l.lanIpRedirectTitle, + content: AppText( + l.lanIpRedirectMessage(url), + variant: AppTextVariant.bodyMedium, + fontWeight: FontWeight.bold, + ), + actions: [ + // No dismiss/close action: once the LAN IP changed, the router is + // unreachable on this origin, so redirecting to https://.local + // is the only valid next step. The dialog is non-dismissible (above). + AppButton.primary( + label: l.lanIpRedirectButton, + onTap: () => navigate(url), + ), + ], + ); +} diff --git a/lib/page/local_network/views/usp_local_network_view.dart b/lib/page/local_network/views/usp_local_network_view.dart index f1692e97f..dc4ba4c1c 100644 --- a/lib/page/local_network/views/usp_local_network_view.dart +++ b/lib/page/local_network/views/usp_local_network_view.dart @@ -11,6 +11,7 @@ import 'package:privacy_gui/page/_shared/components/layout_blocks.dart'; import 'package:privacy_gui/route/constants.dart'; import 'package:privacy_gui/page/local_network/models/local_network_feature_state.dart'; import 'package:privacy_gui/page/local_network/providers/usp_local_network_notifier.dart'; +import 'package:privacy_gui/page/local_network/views/helpers/lan_ip_redirect_dialog.dart'; import 'package:privacy_gui/page/shell/usp_top_bar.dart'; import 'package:ui_kit_library/ui_kit.dart'; @@ -37,6 +38,9 @@ class _UspLocalNetworkViewState extends ConsumerState { late TextEditingController _dns2Controller; late TextEditingController _dns3Controller; + final _hostNameFocus = FocusNode(); + final _leaseTimeFocus = FocusNode(); + @override void initState() { super.initState(); @@ -49,10 +53,32 @@ class _UspLocalNetworkViewState extends ConsumerState { _dns1Controller = TextEditingController(); _dns2Controller = TextEditingController(); _dns3Controller = TextEditingController(); + + _hostNameFocus.addListener(_onTextFieldFocusChange); + _leaseTimeFocus.addListener(_onTextFieldFocusChange); + } + + void _onTextFieldFocusChange() { + if (!_hostNameFocus.hasFocus && !_leaseTimeFocus.hasFocus) { + ref.read(uspLocalNetworkProvider.notifier).validate(); + } + } + + void _onIpv4FocusChanged(int? index, bool hasFocus) { + // Only validate when focus leaves the entire IPv4 field + if (index == null && !hasFocus) { + ref.read(uspLocalNetworkProvider.notifier).validate(); + } } @override void dispose() { + _hostNameFocus.removeListener(_onTextFieldFocusChange); + _leaseTimeFocus.removeListener(_onTextFieldFocusChange); + + _hostNameFocus.dispose(); + _leaseTimeFocus.dispose(); + _hostNameController.dispose(); _ipAddressController.dispose(); _subnetMaskController.dispose(); @@ -108,6 +134,7 @@ class _UspLocalNetworkViewState extends ConsumerState { if (status.error != null) { return ServiceErrorView( error: status.error, + title: loc(context).failedToLoadSettings, onRetry: () => ref .read(uspLocalNetworkProvider.notifier) .fetch(forceRemote: true), @@ -128,13 +155,19 @@ class _UspLocalNetworkViewState extends ConsumerState { WidgetRef ref, LocalNetworkFeatureState state, ) { - if (!state.isDirty) return null; + // Always return a config to keep widget tree stable. + // Returning null when !isDirty causes tree structure change, + // which triggers TextField unfocus on Flutter Web. return UiKitBottomBarConfig( positiveLabel: loc(context).save, - isPositiveEnabled: - !state.status.isSaving && !state.status.hasValidationErrors, - onPositiveTap: () => _onSave(context, ref, state), - onNegativeTap: () => ref.read(uspLocalNetworkProvider.notifier).revert(), + isPositiveEnabled: state.isDirty && + !state.status.isSaving && + !state.status.hasValidationErrors, + isNegativeEnabled: state.isDirty, + onPositiveTap: state.isDirty ? () => _onSave(context, ref, state) : null, + onNegativeTap: state.isDirty + ? () => ref.read(uspLocalNetworkProvider.notifier).revert() + : null, ); } @@ -184,6 +217,7 @@ class _UspLocalNetworkViewState extends ConsumerState { children: [ AppTextFormField( controller: _hostNameController, + focusNode: _hostNameFocus, label: loc(context).hostname, onChanged: (v) => notifier.updateSetting((m) => m.copyWith(hostName: v)), @@ -196,6 +230,7 @@ class _UspLocalNetworkViewState extends ConsumerState { label: loc(context).ipAddress, onChanged: (v) => notifier.updateSetting((m) => m.copyWith(ipAddress: v)), + onFocusChanged: _onIpv4FocusChanged, errorText: errors['ipAddress'], enabled: !disabled, ), @@ -205,6 +240,7 @@ class _UspLocalNetworkViewState extends ConsumerState { label: loc(context).subnetMask, onChanged: (v) => notifier.updateSetting((m) => m.copyWith(subnetMask: v)), + onFocusChanged: _onIpv4FocusChanged, errorText: errors['subnetMask'], enabled: !disabled, ), @@ -270,6 +306,7 @@ class _UspLocalNetworkViewState extends ConsumerState { label: loc(context).poolStart, onChanged: (v) => notifier .updateSetting((m) => m.copyWith(minAddress: v)), + onFocusChanged: _onIpv4FocusChanged, errorText: errors['minAddress'], readOnly: poolReadOnly, enabled: !disabled, @@ -280,6 +317,7 @@ class _UspLocalNetworkViewState extends ConsumerState { label: loc(context).poolEnd, onChanged: (v) => notifier .updateSetting((m) => m.copyWith(maxAddress: v)), + onFocusChanged: _onIpv4FocusChanged, errorText: errors['maxAddress'], readOnly: poolReadOnly, enabled: !disabled, @@ -287,6 +325,7 @@ class _UspLocalNetworkViewState extends ConsumerState { AppGap.md(), AppTextFormField( controller: _leaseTimeController, + focusNode: _leaseTimeFocus, label: loc(context).leaseTimeMinutes, keyboardType: TextInputType.number, onChanged: (v) { @@ -314,6 +353,7 @@ class _UspLocalNetworkViewState extends ConsumerState { label: loc(context).dnsServer1, onChanged: (v) => notifier .updateSetting((m) => m.copyWith(dnsServer1: v)), + onFocusChanged: _onIpv4FocusChanged, errorText: errors['dnsServer1'], enabled: !disabled, ), @@ -323,6 +363,7 @@ class _UspLocalNetworkViewState extends ConsumerState { label: loc(context).dnsServer2, onChanged: (v) => notifier .updateSetting((m) => m.copyWith(dnsServer2: v)), + onFocusChanged: _onIpv4FocusChanged, errorText: errors['dnsServer2'], enabled: !disabled, ), @@ -332,6 +373,7 @@ class _UspLocalNetworkViewState extends ConsumerState { label: loc(context).dnsServer3, onChanged: (v) => notifier .updateSetting((m) => m.copyWith(dnsServer3: v)), + onFocusChanged: _onIpv4FocusChanged, errorText: errors['dnsServer3'], enabled: !disabled, ), @@ -366,8 +408,18 @@ class _UspLocalNetworkViewState extends ConsumerState { WidgetRef ref, LocalNetworkFeatureState state, ) async { + // Read the change intent and hostname BEFORE saving: save() collapses + // original into current (markAsSaved) and may drop SSE, so these must be + // captured up front. The disconnection warning covers any IP/subnet change, + // but only an IP address change makes the old origin unreachable and + // triggers the redirect. hostName is the redirect target + // (https://.local). + final networkChanged = state.hasNetworkChange; + final ipChanged = state.hasIpAddressChange; + final hostName = state.settings.current.model.hostName; + // Warn if router IP or subnet changed (may cause disconnection) - if (state.hasNetworkChange) { + if (networkChanged) { final confirmed = await _showNetworkChangeConfirmation(context); if (confirmed != true || !context.mounted) return; } @@ -377,7 +429,16 @@ class _UspLocalNetworkViewState extends ConsumerState { context, ref.read(uspLocalNetworkProvider.notifier).save(), ); - if (context.mounted) { + if (!context.mounted) return; + + // Only redirect after a confirmed save when the IP address actually + // changed: the old address is now unreachable, so the browser must be + // sent to the router's new .local address. On native, showLanIpRedirect + // Dialog's navigate is a no-op. Otherwise (mask-only or DHCP fields), + // the connection survives — stay on the page with a success message. + if (ipChanged && hostName.isNotEmpty) { + await showLanIpRedirectDialog(context, hostName: hostName); + } else { showSuccessSnackBar(context, loc(context).localNetworkSettingsSaved); } } catch (e) { diff --git a/lib/page/login/views/login_local_view.dart b/lib/page/login/views/login_local_view.dart index d11eb5c5e..4c71f7487 100644 --- a/lib/page/login/views/login_local_view.dart +++ b/lib/page/login/views/login_local_view.dart @@ -107,8 +107,7 @@ class _LoginViewState extends ConsumerState { previous.isLoading && next.hasValue && !next.hasError) { - final loginType = next.value?.loginType; - if (loginType != null && loginType != LoginType.none) { + if (next.value?.isLoggedIn ?? false) { if (!context.mounted) return; context.go('/'); } diff --git a/lib/page/port_forwarding/cards/usp_port_forwarding_card.dart b/lib/page/port_forwarding/cards/usp_port_forwarding_card.dart index 819c6d22b..fc821555c 100644 --- a/lib/page/port_forwarding/cards/usp_port_forwarding_card.dart +++ b/lib/page/port_forwarding/cards/usp_port_forwarding_card.dart @@ -73,74 +73,43 @@ class UspPortForwardingCard extends ConsumerWidget { Widget _buildPortForwardingRow(BuildContext context, WidgetRef ref, PortForwardingRuleUIModel rule, bool isLoading) { - final colorScheme = Theme.of(context).colorScheme; - - return LayoutBlock( - child: Row( - children: [ - AppSwitch( - value: rule.enabled, - scale: 0.8, - onChanged: isLoading - ? null - : (value) => performUspMutation( - context, - ref, - loadingKey: 'portForwarding', - mutation: () => ref - .read(uspPortForwardingPageProvider.notifier) - .immediateToggleForwarding(rule.instancePath!, value), - ), - ), - AppGap.sm(), - Expanded( - child: AppText.bodyMedium(rule.displayName), - ), - AppText.bodySmall( - rule.portSummary, - color: colorScheme.onSurfaceVariant, - ), - AppGap.md(), - _ProtocolBadge(protocol: rule.protocol), - ], - ), + return ToggleRow( + value: rule.enabled, + isLoading: isLoading, + onChanged: isLoading || rule.instancePath == null + ? null + : (value) => performUspMutation( + context, + ref, + loadingKey: 'portForwarding', + mutation: () => ref + .read(uspPortForwardingPageProvider.notifier) + .immediateToggleForwarding(rule.instancePath!, value), + ), + title: rule.displayName, + subtitle: rule.portSummary, + trailing: ProtocolBadge(protocol: rule.protocol), ); } Widget _buildPortTriggeringRow(BuildContext context, WidgetRef ref, PortTriggeringRuleUIModel trigger, bool isLoading) { - final colorScheme = Theme.of(context).colorScheme; - - return LayoutBlock( - child: Row( - children: [ - AppSwitch( - value: trigger.enabled, - scale: 0.8, - onChanged: isLoading - ? null - : (value) => performUspMutation( - context, - ref, - loadingKey: 'portForwarding', - mutation: () => ref - .read(uspPortForwardingPageProvider.notifier) - .immediateToggleTriggering( - trigger.instancePath!, value), - ), - ), - AppGap.sm(), - Expanded( - child: AppText.bodyMedium(trigger.displayName), - ), - AppText.bodySmall( - '${trigger.triggerPortDisplay} → ${trigger.forwardPortDisplay}', - color: colorScheme.onSurfaceVariant, - ), - AppGap.md(), - _ProtocolBadge(protocol: trigger.triggerProtocol), - ], - ), + return ToggleRow( + value: trigger.enabled, + isLoading: isLoading, + onChanged: isLoading || trigger.instancePath == null + ? null + : (value) => performUspMutation( + context, + ref, + loadingKey: 'portForwarding', + mutation: () => ref + .read(uspPortForwardingPageProvider.notifier) + .immediateToggleTriggering(trigger.instancePath!, value), + ), + title: trigger.displayName, + subtitle: '${trigger.triggerPortDisplay} → ${trigger.forwardPortDisplay}', + trailing: ProtocolBadge(protocol: trigger.triggerProtocol), ); } @@ -165,28 +134,7 @@ class UspPortForwardingCard extends ConsumerWidget { description: result.description, enabled: result.enabled, ), - successMessage: 'Rule added', - ); - } -} - -class _ProtocolBadge extends StatelessWidget { - final String protocol; - const _ProtocolBadge({required this.protocol}); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(4), - ), - child: AppText.labelSmall( - protocol, - color: colorScheme.onPrimaryContainer, - ), + successMessage: loc(context).ruleAdded, ); } } diff --git a/lib/page/port_forwarding/models/port_triggering_rule_ui_model.dart b/lib/page/port_forwarding/models/port_triggering_rule_ui_model.dart index 5c391b8f2..44560b45d 100644 --- a/lib/page/port_forwarding/models/port_triggering_rule_ui_model.dart +++ b/lib/page/port_forwarding/models/port_triggering_rule_ui_model.dart @@ -1,10 +1,11 @@ import 'package:equatable/equatable.dart'; +import 'package:privacy_gui/framework/diagnostic_loggable.dart'; /// Presentation Layer Model for a single forwarded-port rule /// within a port trigger entry (child of `Device.NAT.PortTrigger.{i}.Rule.{i}`). /// /// [instancePath] is `null` for locally-created forward rules not yet saved. -class PortTriggerForwardRuleUIModel extends Equatable { +class PortTriggerForwardRuleUIModel extends Equatable with DiagnosticLoggable { final String? instancePath; final int forwardPort; final int forwardPortEndRange; @@ -24,12 +25,15 @@ class PortTriggerForwardRuleUIModel extends Equatable { : '$forwardPort-$forwardPortEndRange'; @override - List get props => [ - instancePath, - forwardPort, - forwardPortEndRange, - forwardProtocol, - ]; + String get diagnosticName => 'PortTriggerForwardRuleUIModel'; + + @override + Map get namedProps => { + 'instancePath': instancePath, + 'forwardPort': forwardPort, + 'forwardPortEndRange': forwardPortEndRange, + 'forwardProtocol': forwardProtocol, + }; } /// Presentation Layer Model for a port triggering rule. @@ -38,7 +42,7 @@ class PortTriggerForwardRuleUIModel extends Equatable { /// `Rule.{i}` sub-table (children). /// /// [instancePath] is `null` for newly created (local-only) rules. -class PortTriggeringRuleUIModel extends Equatable { +class PortTriggeringRuleUIModel extends Equatable with DiagnosticLoggable { final String? instancePath; final bool enabled; final String description; @@ -89,24 +93,27 @@ class PortTriggeringRuleUIModel extends Equatable { /// Forward port display (first rule): "1024-1030" or "—" if no rules. String get forwardPortDisplay => - forwardRules.isNotEmpty ? forwardRules.first.portDisplay : '\u2014'; + forwardRules.isNotEmpty ? forwardRules.first.portDisplay : '—'; /// Forward protocol (first rule) or "—" if no rules. String get forwardProtocolDisplay => - forwardRules.isNotEmpty ? forwardRules.first.forwardProtocol : '\u2014'; + forwardRules.isNotEmpty ? forwardRules.first.forwardProtocol : '—'; /// Summary: "Trigger: 21 TCP → Forward: 1024-1030 TCP". String get summary => 'Trigger: $triggerPortDisplay $triggerProtocol ' - '\u2192 Forward: $forwardPortDisplay $forwardProtocolDisplay'; + '→ Forward: $forwardPortDisplay $forwardProtocolDisplay'; + + @override + String get diagnosticName => 'PortTriggeringRuleUIModel'; @override - List get props => [ - instancePath, - enabled, - description, - triggerPort, - triggerPortEndRange, - triggerProtocol, - forwardRules, - ]; + Map get namedProps => { + 'instancePath': instancePath, + 'enabled': enabled, + 'description': description, + 'triggerPort': triggerPort, + 'triggerPortEndRange': triggerPortEndRange, + 'triggerProtocol': triggerProtocol, + 'forwardRules': forwardRules, + }; } diff --git a/lib/page/port_forwarding/providers/port_forwarding_data_provider.dart b/lib/page/port_forwarding/providers/port_forwarding_data_provider.dart index 06f82a1ae..fb8299e65 100644 --- a/lib/page/port_forwarding/providers/port_forwarding_data_provider.dart +++ b/lib/page/port_forwarding/providers/port_forwarding_data_provider.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:equatable/equatable.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:privacy_gui/core/usp/providers/sse_invalidation_provider.dart'; +import 'package:privacy_gui/framework/diagnostic_loggable.dart'; import 'package:privacy_gui/page/_shared/models/port_forwarding_rule_ui_model.dart'; import 'package:privacy_gui/page/port_forwarding/services/usp_port_forwarding_data_service.dart'; @@ -15,7 +16,7 @@ final portForwardingDataProvider = PortForwardingDataNotifier.new, ); -class PortForwardingData extends Equatable { +class PortForwardingData extends Equatable with DiagnosticLoggable { final List ruleModels; const PortForwardingData({ @@ -23,7 +24,10 @@ class PortForwardingData extends Equatable { }); @override - List get props => [ruleModels.length]; + String get diagnosticName => 'PortForwardingData'; + + @override + Map get namedProps => {'ruleModels': ruleModels}; } class PortForwardingDataNotifier extends AsyncNotifier { diff --git a/lib/page/port_forwarding/providers/port_triggering_data_provider.dart b/lib/page/port_forwarding/providers/port_triggering_data_provider.dart index 758593d13..7922fc122 100644 --- a/lib/page/port_forwarding/providers/port_triggering_data_provider.dart +++ b/lib/page/port_forwarding/providers/port_triggering_data_provider.dart @@ -1,5 +1,6 @@ import 'package:equatable/equatable.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacy_gui/framework/diagnostic_loggable.dart'; import 'package:privacy_gui/page/port_forwarding/models/port_triggering_rule_ui_model.dart'; import 'package:privacy_gui/page/port_forwarding/services/usp_port_triggering_data_service.dart'; @@ -12,7 +13,7 @@ final portTriggeringDataProvider = PortTriggeringDataNotifier.new, ); -class PortTriggeringData extends Equatable { +class PortTriggeringData extends Equatable with DiagnosticLoggable { final List ruleModels; const PortTriggeringData({ @@ -20,7 +21,10 @@ class PortTriggeringData extends Equatable { }); @override - List get props => [ruleModels.length]; + String get diagnosticName => 'PortTriggeringData'; + + @override + Map get namedProps => {'ruleModels': ruleModels}; } class PortTriggeringDataNotifier extends AsyncNotifier { diff --git a/lib/page/port_forwarding/services/usp_port_forwarding_service.dart b/lib/page/port_forwarding/services/usp_port_forwarding_service.dart index 4f584fa17..484c17942 100644 --- a/lib/page/port_forwarding/services/usp_port_forwarding_service.dart +++ b/lib/page/port_forwarding/services/usp_port_forwarding_service.dart @@ -398,7 +398,13 @@ class UspPortForwardingService { } } - // 3. Update (parent-level only) + // 3. Update parent-level fields AND reconcile nested forward rules. + // + // The parent Set (PortTrigger.{i}) only carries the trigger fields + // (Enable/Description/Port/PortEndRange/Protocol). The forwarded ports + // live in a separate sub-table (PortTrigger.{i}.Rule.{j}) and MUST be + // reconciled with their own Add / Set / Delete calls — otherwise edits + // to the forwarded ports are silently dropped (#1061). final originalByPath = { for (final r in original) if (r.instancePath != null) r.instancePath!: r, @@ -408,7 +414,14 @@ class UspPortForwardingService { if (cur.instancePath == null) continue; final orig = originalByPath[cur.instancePath!]; if (orig == null) continue; - if (cur != orig) { + if (cur == orig) continue; + + // 3a. Parent-level trigger fields. + if (cur.enabled != orig.enabled || + cur.description != orig.description || + cur.triggerPort != orig.triggerPort || + cur.triggerPortEndRange != orig.triggerPortEndRange || + cur.triggerProtocol != orig.triggerProtocol) { toUpdate.add(PortTriggerUpdate( instancePath: cur.instancePath!, enabled: cur.enabled, @@ -418,6 +431,17 @@ class UspPortForwardingService { triggerProtocol: cur.triggerProtocol, )); } + + // 3b. Nested forwarded-port rules. + if (cur.forwardRules != orig.forwardRules) { + final (int fwdOps, int fwdFailed) = await _reconcileForwardRules( + parentPath: cur.instancePath!, + original: orig.forwardRules, + current: cur.forwardRules, + ); + totalOps += fwdOps; + failedOps += fwdFailed; + } } if (toUpdate.isNotEmpty) { totalOps++; @@ -454,4 +478,109 @@ class UspPortForwardingService { throw mapUspErrorToServiceError(e); } } + + /// Reconcile the nested forwarded-port sub-rules of a single existing + /// port trigger (parent already persisted at [parentPath]). + /// + /// Diffs [original] vs [current] forward rules and issues the minimal set of + /// Add / Set / Delete calls against the `Rule.{j}` sub-table. Existing rules + /// (non-null instancePath) whose values changed are updated in place via + /// [UspClient.set]; rules that vanished are deleted; brand-new rules + /// (null instancePath) are added. Returns the number of operations attempted + /// and how many failed, so the caller can fold them into its lenient + /// all-or-nothing tally. + Future<(int, int)> _reconcileForwardRules({ + required String parentPath, + required List original, + required List current, + }) async { + int ops = 0; + int failed = 0; + + // 1. Delete forward rules that no longer exist in current. + final currentPaths = { + for (final r in current) + if (r.instancePath != null) r.instancePath!, + }; + final toDelete = original + .where((r) => + r.instancePath != null && !currentPaths.contains(r.instancePath)) + .toList(); + for (final r in toDelete.reversed) { + ops++; + final result = await PortTriggering.deletePortTriggerForwardRule( + _usp, r.instancePath!); + final parsed = UspResultParser.parseDeleteResult(result); + switch (parsed) { + case UspSuccess(): + break; + case UspPartialSuccess(failures: final f): + logger.w( + '[PortTriggering]: Forward delete partial: ${f.first.errorMessage}'); + case UspFailure(errors: final e): + failed++; + logger.w( + '[PortTriggering]: Forward delete failed: ${e.first.errorMessage}'); + } + } + + // 2. Update existing forward rules whose values changed (in-place Set). + final originalByPath = { + for (final r in original) + if (r.instancePath != null) r.instancePath!: r, + }; + final updateParams = {}; + for (final cur in current) { + if (cur.instancePath == null) continue; + final orig = originalByPath[cur.instancePath!]; + if (orig == null) continue; + if (cur == orig) continue; + updateParams['${cur.instancePath}Port'] = cur.forwardPort; + updateParams['${cur.instancePath}PortEndRange'] = cur.forwardPortEndRange; + updateParams['${cur.instancePath}Protocol'] = cur.forwardProtocol; + } + if (updateParams.isNotEmpty) { + ops++; + final result = await _usp.set(updateParams); + final parsed = UspResultParser.parseSetResult(result); + switch (parsed) { + case UspSuccess(): + break; + case UspPartialSuccess(failures: final f): + logger.w( + '[PortTriggering]: Forward update partial: ${f.first.errorMessage}'); + case UspFailure(errors: final e): + failed++; + logger.w( + '[PortTriggering]: Forward update failed: ${e.first.errorMessage}'); + } + } + + // 3. Add brand-new forward rules (null instancePath). + final toAdd = current.where((r) => r.instancePath == null).toList(); + for (final fr in toAdd) { + ops++; + final result = await PortTriggering.addPortTriggerForwardRule( + _usp, + parentPath, + forwardPort: fr.forwardPort, + forwardPortEndRange: fr.forwardPortEndRange, + forwardProtocol: fr.forwardProtocol, + ); + final parsed = UspResultParser.parseAddResult(result); + switch (parsed) { + case UspSuccess(): + break; + case UspPartialSuccess(failures: final f): + logger.w( + '[PortTriggering]: Forward add partial: ${f.first.errorMessage}'); + case UspFailure(errors: final e): + failed++; + logger.w( + '[PortTriggering]: Forward add failed: ${e.first.errorMessage}'); + } + } + + return (ops, failed); + } } diff --git a/lib/page/port_forwarding/views/components/usp_port_range_tab.dart b/lib/page/port_forwarding/views/components/usp_port_range_tab.dart index b60f47bbf..89ee17ed6 100644 --- a/lib/page/port_forwarding/views/components/usp_port_range_tab.dart +++ b/lib/page/port_forwarding/views/components/usp_port_range_tab.dart @@ -29,7 +29,8 @@ class UspPortRangeTab extends ConsumerWidget { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - AppText.titleMedium(loc(context).portRangeForwarding), + AppText.titleMedium( + '${loc(context).portRangeForwarding} (${rules.length})'), AppIconButton( icon: AppIcon.font(Icons.add, size: 20), onTap: isSaving ? null : () => _showAddDialog(context, ref), diff --git a/lib/page/port_forwarding/views/components/usp_port_triggering_tab.dart b/lib/page/port_forwarding/views/components/usp_port_triggering_tab.dart index 6a45a6add..be715441f 100644 --- a/lib/page/port_forwarding/views/components/usp_port_triggering_tab.dart +++ b/lib/page/port_forwarding/views/components/usp_port_triggering_tab.dart @@ -29,7 +29,8 @@ class UspPortTriggeringTab extends ConsumerWidget { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - AppText.titleMedium(loc(context).portTriggering), + AppText.titleMedium( + '${loc(context).portTriggering} (${rules.length})'), AppIconButton( icon: AppIcon.font(Icons.add, size: 20), onTap: isSaving ? null : () => _showAddDialog(context, ref), @@ -133,6 +134,23 @@ class UspPortTriggeringTab extends ConsumerWidget { triggerPort: result.triggerPort, triggerPortEndRange: result.triggerPortEndRange, triggerProtocol: result.triggerProtocol, + // Rebuild the forwarded-port rule from the dialog result so edits + // to the forwarded ports actually persist. Preserve the existing + // first rule's instancePath so the service updates it in place + // (rather than dropping the edit). Any additional forward rules + // beyond the first — which the single-mapping dialog cannot show — + // are carried through untouched. + forwardRules: [ + PortTriggerForwardRuleUIModel( + instancePath: rule.forwardRules.isNotEmpty + ? rule.forwardRules.first.instancePath + : null, + forwardPort: result.forwardPort, + forwardPortEndRange: result.forwardPortEndRange, + forwardProtocol: result.forwardProtocol, + ), + ...rule.forwardRules.skip(1), + ], ), ); } diff --git a/lib/page/port_forwarding/views/components/usp_single_port_tab.dart b/lib/page/port_forwarding/views/components/usp_single_port_tab.dart index 345fe6437..d7ee008a8 100644 --- a/lib/page/port_forwarding/views/components/usp_single_port_tab.dart +++ b/lib/page/port_forwarding/views/components/usp_single_port_tab.dart @@ -30,7 +30,8 @@ class UspSinglePortTab extends ConsumerWidget { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - AppText.titleMedium(loc(context).singlePortForwarding), + AppText.titleMedium( + '${loc(context).singlePortForwarding} (${rules.length})'), AppIconButton( icon: AppIcon.font(Icons.add, size: 20), onTap: isSaving ? null : () => _showAddDialog(context, ref), @@ -101,7 +102,7 @@ class UspSinglePortTab extends ConsumerWidget { List _buildIpv4DeviceOptions(WidgetRef ref) { final devices = - ref.read(devicesDataProvider).valueOrNull?.deviceModels ?? []; + ref.read(devicesDataProvider).valueOrNull?.clientDevices ?? []; return devices .where((d) => d.ip.isNotEmpty) .map((d) => AppAutoCompleteOption( diff --git a/lib/page/port_forwarding/views/usp_port_forwarding_detail_view.dart b/lib/page/port_forwarding/views/usp_port_forwarding_detail_view.dart index f8827d02e..e2ebb1b57 100644 --- a/lib/page/port_forwarding/views/usp_port_forwarding_detail_view.dart +++ b/lib/page/port_forwarding/views/usp_port_forwarding_detail_view.dart @@ -75,9 +75,9 @@ class _UspPortForwardingDetailViewState .fetch(forceRemote: true), bottomBar: _buildBottomBar(context, ref, pageState), tabs: [ - Tab(text: loc(context).singlePortWithCount(singlePortRules.length)), - Tab(text: loc(context).portRangeWithCount(portRangeRules.length)), - Tab(text: loc(context).triggeringWithCount(triggeringRules.length)), + Tab(text: loc(context).singlePortForwarding), + Tab(text: loc(context).portRangeForwarding), + Tab(text: loc(context).portTriggering), ], tabContentViews: [ _buildTabContent( @@ -129,6 +129,7 @@ class _UspPortForwardingDetailViewState if (status.error != null) { return ServiceErrorView( error: status.error, + title: loc(context).failedToLoadSettings, onRetry: () => ref .read(uspPortForwardingPageProvider.notifier) .fetch(forceRemote: true), diff --git a/lib/page/remote_assistance/views/remote_assistance_session_guard.dart b/lib/page/remote_assistance/views/remote_assistance_session_guard.dart index d753f18a0..6c9a8e58b 100644 --- a/lib/page/remote_assistance/views/remote_assistance_session_guard.dart +++ b/lib/page/remote_assistance/views/remote_assistance_session_guard.dart @@ -62,8 +62,7 @@ class _RemoteAssistanceSessionGuardState Future _checkAndRestoreSession() async { // Skip for Remote Assistance mode (CA side) - double check - final loginType = ref.read(authProvider).value?.loginType; - if (loginType == LoginType.remote) return; + if (ref.read(authProvider).value?.isRemoteAssistance ?? false) return; // Get device credentials from unified provider final credentials = ref.read(deviceCredentialsProvider); diff --git a/lib/page/shell/usp_dashboard_shell.dart b/lib/page/shell/usp_dashboard_shell.dart index 6f476da3e..dcd889975 100644 --- a/lib/page/shell/usp_dashboard_shell.dart +++ b/lib/page/shell/usp_dashboard_shell.dart @@ -94,7 +94,7 @@ class _UspDashboardShellState extends ConsumerState { title: loc(context).connectionLost, message: 'Lost connection to the router. Attempting to reconnect automatically...', - successMessage: 'Reconnected to router', + successMessage: loc(context).reconnectedToRouter, ); } finally { _recoveryDialogShowing = false; diff --git a/lib/page/shell/usp_top_bar.dart b/lib/page/shell/usp_top_bar.dart index f1cdbff10..786425c3d 100644 --- a/lib/page/shell/usp_top_bar.dart +++ b/lib/page/shell/usp_top_bar.dart @@ -12,70 +12,94 @@ import 'package:privacy_gui/providers/auth/_auth.dart'; import 'package:privacy_gui/providers/theme_config_provider.dart'; import 'package:privacy_gui/page/shell/usp_dashboard_shell.dart'; import 'package:privacy_gui/route/constants.dart'; +import 'package:privacy_gui/util/debug_mixin.dart'; +import 'package:privacy_gui/util/app_utils.dart'; import 'package:go_router/go_router.dart'; import 'package:ui_kit_library/ui_kit.dart'; -/// USP-specific TopBar — visually matches the JNAP TopBar but without -/// JNAP-specific provider dependencies (deviceManager, remoteClient, etc.). +/// Unified TopBar for the app. /// -/// Structure: [App Title] — [MenuHolder top (desktop)] — [GeneralSettingsWidget] -class UspTopBar extends ConsumerWidget { - const UspTopBar({super.key}); +/// Structure: [App Title] — [MenuHolder top (desktop)] — [Apps button (if logged in)] — [GeneralSettingsWidget] +/// +/// Supports: +/// - Optional [controllerProvider] for custom menu controller (defaults to uspMenuController) +/// - Download log via rapid taps on the title area (DebugObserver) +/// - Apps button visibility based on login state and capability +class UspTopBar extends ConsumerStatefulWidget { + final Provider? controllerProvider; + + const UspTopBar({super.key, this.controllerProvider}); + + @override + ConsumerState createState() => _UspTopBarState(); +} +class _UspTopBarState extends ConsumerState with DebugObserver { @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { // Build dark theme reactively from current design style - final darkTheme = _buildCurrentDarkTheme(ref); + final darkTheme = _buildCurrentDarkTheme(); final colorScheme = darkTheme.colorScheme; return SafeArea( bottom: false, - child: Theme( - data: darkTheme, - child: AppSurface( - height: 64, - padding: const EdgeInsets.only(left: 24.0, right: 24), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - AppText.titleLarge( - loc(context).appTitle, - color: colorScheme.onSurface, - ), - MenuHolder( - type: MenuDisplay.top, - controllerProvider: uspMenuController, - ), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (ref.watch(authProvider.select((v) => - v.value?.loginType != null && - v.value?.loginType != LoginType.none)) && - (ref.watch(appsCapabilityProvider).valueOrNull ?? false)) - IconButton( - icon: AppIcon.font(Icons.apps, - color: colorScheme.onSurface), - tooltip: loc(context).apps, - onPressed: () => context.goNamed(RouteNamed.uspApps), + child: GestureDetector( + onTap: () { + if (increase()) { + Utils.exportLogFile(context); + } + }, + child: Theme( + data: darkTheme, + child: AppSurface( + height: 64, + padding: const EdgeInsets.only(left: 24.0, right: 24), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + AppText.titleLarge( + loc(context).appTitle, + color: colorScheme.onSurface, + ), + MenuHolder( + type: MenuDisplay.top, + controllerProvider: + widget.controllerProvider ?? uspMenuController, + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (ref.watch(authProvider + .select((v) => v.value?.isLoggedIn ?? false)) && + (ref.watch(appsCapabilityProvider).valueOrNull ?? + false)) + Tooltip( + message: loc(context).apps, + child: AppIconButton( + icon: AppIcon.font(Icons.apps, + color: colorScheme.onSurface), + onTap: () => context.goNamed(RouteNamed.uspApps), + ), + ), + const Padding( + padding: EdgeInsets.all(4.0), + child: GeneralSettingsWidget(), ), - const Padding( - padding: EdgeInsets.all(4.0), - child: GeneralSettingsWidget(), - ), - ], - ), - ], + ], + ), + ], + ), ), ), ), ); } - ThemeData _buildCurrentDarkTheme(WidgetRef ref) { + ThemeData _buildCurrentDarkTheme() { final demoConfig = ref.watch(demoThemeConfigProvider); - final themeConfig = ref.watch(themeConfigProvider).valueOrNull; + final themeConfig = + ref.watch(themeConfigProvider.select((v) => v.valueOrNull)); final userThemeColor = ref.watch(appSettingsProvider.select((s) => s.themeColor)); diff --git a/lib/page/static_routing/services/usp_static_routing_service.dart b/lib/page/static_routing/services/usp_static_routing_service.dart index 6b3d6f8b4..9d2dc3d93 100644 --- a/lib/page/static_routing/services/usp_static_routing_service.dart +++ b/lib/page/static_routing/services/usp_static_routing_service.dart @@ -7,11 +7,25 @@ import 'package:privacy_gui/core/usp/providers/usp_client_provider.dart'; import 'package:privacy_gui/core/usp/services/usp_client.dart'; import 'package:privacy_gui/page/static_routing/models/static_routing_ui_model.dart'; import 'package:privacy_gui/util/network_utils.dart'; +import 'package:privacy_gui/validator_rules/rules.dart'; final uspStaticRoutingServiceProvider = Provider( (ref) => UspStaticRoutingService(ref.read(uspClientProvider)!), ); +/// Error keys returned by [UspStaticRoutingService.validateRoute]. +/// Use these to map to l10n strings in the View layer. +class StaticRoutingErrorKeys { + static const nameRequired = 'nameRequired'; + static const nameTooLong = 'nameTooLong'; + static const destIpRequired = 'destIpRequired'; + static const invalidIpAddress = 'invalidIpAddress'; + static const subnetMaskRequired = 'subnetMaskRequired'; + static const invalidSubnetMask = 'invalidSubnetMask'; + static const gatewayMustBeWithinLanSubnet = 'gatewayMustBeWithinLanSubnet'; + static const gatewayMustBeOutsideLanSubnet = 'gatewayMustBeOutsideLanSubnet'; +} + /// Service layer for Static Routing — encapsulates codegen CRUD + transform + validation. class UspStaticRoutingService { final UspClient _usp; @@ -198,30 +212,57 @@ class UspStaticRoutingService { // --------------------------------------------------------------------------- /// Validate a route entry. Returns a map of field → error message. + /// + /// When [lanIp] and [lanSubnetMask] are provided, validates gateway subnet: + /// - LAN interface: gateway must be within LAN subnet + /// - Internet interface: gateway must be outside LAN subnet static Map validateRoute({ required String name, required String destIp, required String subnetMask, required String gateway, + String? interfaceName, + String? lanIp, + String? lanSubnetMask, }) { final errors = {}; if (name.isEmpty) { - errors['name'] = 'Name is required'; + errors['name'] = StaticRoutingErrorKeys.nameRequired; } else if (name.length > 32) { - errors['name'] = 'Name must be 32 characters or less'; + errors['name'] = StaticRoutingErrorKeys.nameTooLong; } if (destIp.isEmpty) { - errors['destIp'] = 'Destination IP is required'; + errors['destIp'] = StaticRoutingErrorKeys.destIpRequired; } else if (!NetworkUtils.isValidIpAddress(destIp)) { - errors['destIp'] = 'Invalid IP address'; + errors['destIp'] = StaticRoutingErrorKeys.invalidIpAddress; } if (subnetMask.isEmpty) { - errors['subnetMask'] = 'Subnet mask is required'; + errors['subnetMask'] = StaticRoutingErrorKeys.subnetMaskRequired; } else if (!NetworkUtils.isValidSubnetMask(subnetMask)) { - errors['subnetMask'] = 'Invalid subnet mask'; + errors['subnetMask'] = StaticRoutingErrorKeys.invalidSubnetMask; } if (gateway.isNotEmpty && !NetworkUtils.isValidIpAddress(gateway)) { - errors['gateway'] = 'Invalid IP address'; + errors['gateway'] = StaticRoutingErrorKeys.invalidIpAddress; + } else if (gateway.isNotEmpty && + interfaceName != null && + lanIp != null && + lanIp.isNotEmpty && + lanSubnetMask != null && + lanSubnetMask.isNotEmpty && + NetworkUtils.isValidIpAddress(lanIp) && + NetworkUtils.isValidSubnetMask(lanSubnetMask)) { + // Validate gateway subnet based on interface selection. + final lanSubnetRule = HostValidForGivenRouterIPAddressAndSubnetMaskRule( + lanIp, + lanSubnetMask, + ); + final isInLan = lanSubnetRule.validate(gateway); + if (interfaceName == 'LAN' && !isInLan) { + errors['gateway'] = StaticRoutingErrorKeys.gatewayMustBeWithinLanSubnet; + } else if (interfaceName == 'Internet' && isInLan) { + errors['gateway'] = + StaticRoutingErrorKeys.gatewayMustBeOutsideLanSubnet; + } } return errors; } diff --git a/lib/page/static_routing/views/dialogs/static_route_dialog.dart b/lib/page/static_routing/views/dialogs/static_route_dialog.dart index 8144ecc7c..a8fb3a72d 100644 --- a/lib/page/static_routing/views/dialogs/static_route_dialog.dart +++ b/lib/page/static_routing/views/dialogs/static_route_dialog.dart @@ -26,10 +26,18 @@ class StaticRouteDialogResult { /// Dialog for adding or editing a static route. /// /// Pass [route] to pre-fill for editing; omit for adding. +/// Pass [lanIp] and [lanSubnetMask] to enable gateway subnet validation. class StaticRouteDialog extends StatefulWidget { final StaticRouteUIModel? route; + final String? lanIp; + final String? lanSubnetMask; - const StaticRouteDialog({super.key, this.route}); + const StaticRouteDialog({ + super.key, + this.route, + this.lanIp, + this.lanSubnetMask, + }); @override State createState() => _StaticRouteDialogState(); @@ -58,6 +66,10 @@ class _StaticRouteDialogState extends State { _gatewayController = TextEditingController(text: r?.gatewayIpAddress ?? ''); _interfaceName = r?.interfaceName ?? 'LAN'; _enabled = r?.enabled ?? true; + // Pre-populate validation state only in edit mode. On add-dialog open all + // fields are empty, so computing errors immediately would show "required" + // errors before the user has typed anything (confusing, non-standard UX). + _errors = _isEdit ? _computeErrors() : {}; } @override @@ -69,27 +81,50 @@ class _StaticRouteDialogState extends State { super.dispose(); } - void _validate() { - setState(() { - _errors = UspStaticRoutingService.validateRoute( - name: _nameController.text.trim(), - destIp: _destIpController.text.trim(), - subnetMask: _subnetMaskController.text.trim(), - gateway: _gatewayController.text.trim(), - ); - }); - } - - bool get _isFormValid { - final errors = UspStaticRoutingService.validateRoute( + Map _computeErrors() { + return UspStaticRoutingService.validateRoute( name: _nameController.text.trim(), destIp: _destIpController.text.trim(), subnetMask: _subnetMaskController.text.trim(), gateway: _gatewayController.text.trim(), + interfaceName: _interfaceName, + lanIp: widget.lanIp, + lanSubnetMask: widget.lanSubnetMask, ); - return errors.isEmpty; } + void _validate() { + setState(() { + _errors = _computeErrors(); + }); + } + + /// Convert error key to localized string. + String? _localizeError(String? key) { + if (key == null) return null; + final l = loc(context); + return switch (key) { + StaticRoutingErrorKeys.nameRequired => l.invalidInput, + StaticRoutingErrorKeys.nameTooLong => l.invalidInput, + StaticRoutingErrorKeys.destIpRequired => l.ipAddressRequired, + StaticRoutingErrorKeys.invalidIpAddress => l.invalidIpAddress, + StaticRoutingErrorKeys.subnetMaskRequired => l.invalidInput, + StaticRoutingErrorKeys.invalidSubnetMask => l.invalidInput, + StaticRoutingErrorKeys.gatewayMustBeWithinLanSubnet => + l.gatewayMustBeWithinLanSubnet, + StaticRoutingErrorKeys.gatewayMustBeOutsideLanSubnet => + l.gatewayMustBeOutsideLanSubnet, + _ => key, + }; + } + + // Compute validity from the live field values rather than the cached + // [_errors] map. In add mode [_errors] starts empty (errors are suppressed + // on open for UX), so relying on it would enable the Save button on a blank + // form and allow submitting an empty route. Recomputing here reflects the + // true form state without surfacing "required" errors before the user types. + bool get _isFormValid => _computeErrors().isEmpty; + @override Widget build(BuildContext context) { return AlertDialog( @@ -102,28 +137,28 @@ class _StaticRouteDialogState extends State { AppTextField( controller: _nameController, hintText: loc(context).routeName, - errorText: _errors['name'], + errorText: _localizeError(_errors['name']), onChanged: (_) => _validate(), ), AppGap.lg(), AppIpv4TextField( controller: _destIpController, label: loc(context).destinationIp, - errorText: _errors['destIp'], + errorText: _localizeError(_errors['destIp']), onChanged: (_) => _validate(), ), AppGap.lg(), AppIpv4TextField( controller: _subnetMaskController, label: loc(context).subnetMask, - errorText: _errors['subnetMask'], + errorText: _localizeError(_errors['subnetMask']), onChanged: (_) => _validate(), ), AppGap.lg(), AppIpv4TextField( controller: _gatewayController, label: loc(context).gatewayIp, - errorText: _errors['gateway'], + errorText: _localizeError(_errors['gateway']), onChanged: (_) => _validate(), ), AppGap.lg(), @@ -137,8 +172,10 @@ class _StaticRouteDialogState extends State { ButtonSegment(value: name, label: Text(name))) .toList(), selected: {_interfaceName}, - onSelectionChanged: (v) => - setState(() => _interfaceName = v.first), + onSelectionChanged: (v) { + setState(() => _interfaceName = v.first); + _validate(); + }, ), ], ), diff --git a/lib/page/static_routing/views/usp_static_routing_view.dart b/lib/page/static_routing/views/usp_static_routing_view.dart index eaae9021c..ed90e75fe 100644 --- a/lib/page/static_routing/views/usp_static_routing_view.dart +++ b/lib/page/static_routing/views/usp_static_routing_view.dart @@ -9,6 +9,7 @@ import 'package:privacy_gui/localization/localization_hook.dart'; import 'package:privacy_gui/page/_shared/components/detail_widgets.dart'; import 'package:privacy_gui/page/_shared/components/layout_blocks.dart'; import 'package:privacy_gui/route/constants.dart'; +import 'package:privacy_gui/page/local_network/providers/lan_data_provider.dart'; import 'package:privacy_gui/page/static_routing/models/static_routing_feature_state.dart'; import 'package:privacy_gui/page/static_routing/models/static_routing_ui_model.dart'; import 'package:privacy_gui/page/static_routing/providers/usp_static_routing_notifier.dart'; @@ -43,6 +44,7 @@ class UspStaticRoutingView extends ConsumerWidget { if (status.error != null) { return ServiceErrorView( error: status.error, + title: loc(context).failedToLoadSettings, onRetry: () => ref .read(uspStaticRoutingProvider.notifier) .fetch(forceRemote: true), @@ -189,9 +191,29 @@ class UspStaticRoutingView extends ConsumerWidget { // --------------------------------------------------------------------------- Future _showAddDialog(BuildContext context, WidgetRef ref) async { + // Await the LAN data so subnet validation is never bypassed by a fast tap + // while lanDataProvider is still AsyncLoading (cold start / network reset). + // valueOrNull would return null there and silently skip validateRoute's + // gateway-subnet block — the core #1082 fix. + final LanData lanData; + try { + lanData = await ref.read(lanDataProvider.future); + } catch (e) { + // lanDataProvider can be in AsyncError (router offline, USP timeout, + // auth failure). Surface it like _onSave instead of letting the throw + // propagate through the onTap lambda and get swallowed silently. + if (context.mounted) { + showFailedSnackBar(context, localizeServiceError(context, e)); + } + return; + } + if (!context.mounted) return; final result = await showAppDialog( context: context, - builder: (_) => const StaticRouteDialog(), + builder: (_) => StaticRouteDialog( + lanIp: lanData.model.ipAddress, + lanSubnetMask: lanData.model.subnetMask, + ), ); if (result == null || !context.mounted) return; ref.read(uspStaticRoutingProvider.notifier).addRoute( @@ -208,9 +230,26 @@ class UspStaticRoutingView extends ConsumerWidget { Future _showEditDialog(BuildContext context, WidgetRef ref, int index, StaticRouteUIModel route) async { + // Await LAN data (see _showAddDialog) so edit-mode subnet validation is not + // bypassed while lanDataProvider is still loading. + final LanData lanData; + try { + lanData = await ref.read(lanDataProvider.future); + } catch (e) { + // See _showAddDialog: surface AsyncError instead of swallowing it. + if (context.mounted) { + showFailedSnackBar(context, localizeServiceError(context, e)); + } + return; + } + if (!context.mounted) return; final result = await showAppDialog( context: context, - builder: (_) => StaticRouteDialog(route: route), + builder: (_) => StaticRouteDialog( + route: route, + lanIp: lanData.model.ipAddress, + lanSubnetMask: lanData.model.subnetMask, + ), ); if (result == null || !context.mounted) return; ref.read(uspStaticRoutingProvider.notifier).editRoute( diff --git a/lib/page/statistics/views/sections/stats_wifi_signal_section.dart b/lib/page/statistics/views/sections/stats_wifi_signal_section.dart index a18e60ecf..f25c88eb7 100644 --- a/lib/page/statistics/views/sections/stats_wifi_signal_section.dart +++ b/lib/page/statistics/views/sections/stats_wifi_signal_section.dart @@ -53,7 +53,7 @@ class StatsWifiSignalSection extends ConsumerWidget { for (final entry in wifiData.wifiClientMap.entries) { final client = entry.value; if (!client.active) continue; - final device = devicesData?.deviceModels + final device = devicesData?.clientDevices .where((d) => d.mac.toUpperCase() == entry.key.toUpperCase()) .firstOrNull; final name = device?.hostName ?? entry.key; diff --git a/lib/page/statistics/views/sections/stats_wifi_speed_section.dart b/lib/page/statistics/views/sections/stats_wifi_speed_section.dart index 5e815657c..0026c065e 100644 --- a/lib/page/statistics/views/sections/stats_wifi_speed_section.dart +++ b/lib/page/statistics/views/sections/stats_wifi_speed_section.dart @@ -52,7 +52,7 @@ class StatsWifiSpeedSection extends ConsumerWidget { for (final entry in wifiData.wifiClientMap.entries) { final client = entry.value; if (!client.active) continue; - final device = devicesData?.deviceModels + final device = devicesData?.clientDevices .where((d) => d.mac.toUpperCase() == entry.key.toUpperCase()) .firstOrNull; final name = device?.hostName ?? entry.key; diff --git a/lib/page/statistics/views/usp_statistics_view.dart b/lib/page/statistics/views/usp_statistics_view.dart index 91c6a8540..419c21b44 100644 --- a/lib/page/statistics/views/usp_statistics_view.dart +++ b/lib/page/statistics/views/usp_statistics_view.dart @@ -10,7 +10,9 @@ import 'package:ui_kit_library/ui_kit.dart'; /// USP Statistics / Monitoring page — consolidates all dashboard chart views /// into 3 scrollable category tabs: Network, Devices, System. class UspStatisticsView extends ConsumerStatefulWidget { - const UspStatisticsView({super.key}); + final int initialTab; + + const UspStatisticsView({super.key, this.initialTab = 0}); @override ConsumerState createState() => _UspStatisticsViewState(); @@ -23,7 +25,11 @@ class _UspStatisticsViewState extends ConsumerState @override void initState() { super.initState(); - _tabController = TabController(length: 3, vsync: this); + _tabController = TabController( + length: 3, + vsync: this, + initialIndex: widget.initialTab.clamp(0, 2), + ); } @override diff --git a/lib/page/system_log/views/usp_system_log_view.dart b/lib/page/system_log/views/usp_system_log_view.dart index 461ca8f40..39e26e692 100644 --- a/lib/page/system_log/views/usp_system_log_view.dart +++ b/lib/page/system_log/views/usp_system_log_view.dart @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:privacy_gui/components/localizations/service_error_localizations.dart'; import 'package:privacy_gui/components/ui_kit_page_view.dart'; +import 'package:privacy_gui/components/views/service_error_view.dart'; +import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/localization/localization_hook.dart'; import 'package:privacy_gui/route/constants.dart'; import 'package:privacy_gui/page/shell/usp_top_bar.dart'; @@ -29,34 +30,17 @@ class UspSystemLogView extends ConsumerWidget { child: (childContext, constraints) { return asyncState.when( loading: () => const Center(child: AppLoader()), - error: (error, stack) => _buildError(context, ref, error), + error: (error, stack) => ServiceErrorView( + error: error is ServiceError ? error : null, + title: loc(context).failedToLoadSettings, + onRetry: () => ref.invalidate(uspSystemLogProvider), + ), data: (logFiles) => _buildContent(context, logFiles), ); }, ); } - Widget _buildError(BuildContext context, WidgetRef ref, Object error) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - AppIcon.font(Icons.error_outline, - size: 48, color: Theme.of(context).colorScheme.error), - AppGap.xl(), - AppText.titleMedium(loc(context).failedToLoadSettings), - AppGap.md(), - AppText.bodyMedium(localizeServiceError(context, error)), - AppGap.xxl(), - AppButton( - label: loc(context).retry, - onTap: () => ref.invalidate(uspSystemLogProvider), - ), - ], - ), - ); - } - Widget _buildContent(BuildContext context, List logFiles) { if (logFiles.isEmpty) { return Center( diff --git a/lib/page/topology/cards/usp_network_topology_card.dart b/lib/page/topology/cards/usp_network_topology_card.dart index 37b9005dc..6fb65f0d0 100644 --- a/lib/page/topology/cards/usp_network_topology_card.dart +++ b/lib/page/topology/cards/usp_network_topology_card.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:privacy_gui/localization/localization_hook.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_network.dart'; import 'package:privacy_gui/page/_shared/models/system_info_ui_model.dart'; import 'package:privacy_gui/page/_shared/components/dashboard_card_template.dart'; import 'package:privacy_gui/page/admin/providers/system_info_data_provider.dart'; @@ -9,7 +9,6 @@ import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; import 'package:privacy_gui/page/_shared/components/card_skeleton.dart'; import 'package:privacy_gui/page/topology/helpers/topology_node_content_builder.dart'; import 'package:privacy_gui/page/topology/helpers/usp_topology_builder.dart'; -import 'package:privacy_gui/page/topology/models/node_ui_model.dart'; import 'package:privacy_gui/page/topology/views/components/node_detail_popup.dart'; import 'package:privacy_gui/route/constants.dart'; import 'package:ui_kit_library/ui_kit.dart'; @@ -22,14 +21,12 @@ import 'package:ui_kit_library/ui_kit.dart'; /// gateway and their connected clients. class UspNetworkTopologyCard extends ConsumerWidget { final SystemInfoUIModel? info; - final List? devices; - final List? nodeModels; + final MeshNetwork? meshNetwork; const UspNetworkTopologyCard({ super.key, this.info, - this.devices, - this.nodeModels, + this.meshNetwork, }); @override @@ -37,17 +34,16 @@ class UspNetworkTopologyCard extends ConsumerWidget { final devicesData = ref.watch(devicesDataProvider).valueOrNull; final info = this.info ?? ref.watch(systemInfoDataProvider).valueOrNull?.model; - if (info == null) return const CardSkeleton.topology(); - final devices = this.devices ?? devicesData?.deviceModels ?? []; - final nodeModels = this.nodeModels ?? devicesData?.nodeModels ?? []; - final topology = UspTopologyBuilder.build( + final meshNetwork = this.meshNetwork ?? devicesData?.meshNetwork; + if (info == null || meshNetwork == null) + return const CardSkeleton.topology(); + + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, info: info, - devices: devices, - nodeModels: nodeModels, ); - final onlineCount = devicesData?.onlineClientCount ?? - devices.where((d) => d.isActive).length; - final totalCount = devicesData?.totalClientCount ?? devices.length; + final onlineCount = meshNetwork.onlineClientCount; + final totalCount = meshNetwork.totalClientCount; final useRing = totalCount >= 8; return DashboardCardTemplate( @@ -55,7 +51,7 @@ class UspNetworkTopologyCard extends ConsumerWidget { titleBadge: AppBadge( label: loc(context) .nOnlineOfTotal(onlineCount.toString(), totalCount.toString())), - detailRoute: RouteNamed.uspDeviceList, + detailRoute: RouteNamed.uspTopology, scrollable: false, content: ClipRect( child: _withTopologyAnimation( diff --git a/lib/page/topology/helpers/usp_topology_builder.dart b/lib/page/topology/helpers/usp_topology_builder.dart index 66464e91d..82bbed899 100644 --- a/lib/page/topology/helpers/usp_topology_builder.dart +++ b/lib/page/topology/helpers/usp_topology_builder.dart @@ -3,9 +3,10 @@ import 'package:privacy_gui/core/utils/device_image_helper.dart'; import 'package:privacy_gui/core/utils/icon_rules.dart'; import 'package:privacy_gui/core/utils/logger.dart'; import 'package:privacy_gui/core/utils/wifi.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart' + hide ConnectionType; +import 'package:privacy_gui/page/_shared/models/mesh_network.dart'; import 'package:privacy_gui/page/_shared/models/system_info_ui_model.dart'; -import 'package:privacy_gui/page/topology/models/node_ui_model.dart'; import 'package:ui_kit_library/ui_kit.dart'; /// Builds a [MeshTopology] from USP dashboard state for [AppTopology] widget. @@ -14,162 +15,150 @@ import 'package:ui_kit_library/ui_kit.dart'; class UspTopologyBuilder { UspTopologyBuilder._(); - static MeshTopology build({ + /// Builds topology from new [MeshNetwork] architecture. + /// + /// Preferred method — uses SSoT container with pre-organized nodes and clients. + static MeshTopology buildFromMeshNetwork({ + required MeshNetwork meshNetwork, required SystemInfoUIModel info, - required List devices, - required List nodeModels, }) { final nodes = []; final links = []; - // Find master node from nodeModels - final masterNode = nodeModels.master; + final master = meshNetwork.master; - // Gateway node (the router) + // Gateway node const gatewayId = 'gateway'; - // Use master node's deviceId (MAC) for navigation, fallback to 'gateway' - final gatewayDeviceId = masterNode?.deviceId ?? 'gateway'; - final gatewayIconName = routerIconTestByModel( - modelNumber: info.modelName, + modelNumber: master.model.isNotEmpty ? master.model : info.modelName, hardwareVersion: info.hardwareVersion, ); nodes.add(MeshNode( id: gatewayId, - name: masterNode?.displayName ?? info.gatewayName, + name: + master.displayName.isNotEmpty ? master.displayName : info.gatewayName, type: MeshNodeType.gateway, status: MeshNodeStatus.online, image: DeviceImageHelper.getRouterImage(gatewayIconName), - extra: info.manufacturer, + extra: master.manufacturer.isNotEmpty + ? master.manufacturer + : info.manufacturer, level: 1.0, metadata: { - 'deviceId': gatewayDeviceId, - 'model': masterNode?.model ?? info.modelName, - 'manufacturer': masterNode?.manufacturer ?? info.manufacturer, - 'serialNumber': masterNode?.serialNumber ?? info.serialNumber, - 'softwareVersion': masterNode?.softwareVersion ?? info.softwareVersion, + 'deviceId': master.deviceId, + 'model': master.model.isNotEmpty ? master.model : info.modelName, + 'manufacturer': master.manufacturer.isNotEmpty + ? master.manufacturer + : info.manufacturer, + 'serialNumber': master.serialNumber.isNotEmpty + ? master.serialNumber + : info.serialNumber, + 'softwareVersion': master.softwareVersion.isNotEmpty + ? master.softwareVersion + : info.softwareVersion, 'isMaster': true, }, )); - // Mesh extender nodes (slave nodes) - final slaveNodes = nodeModels.slaves; - final hasMesh = nodeModels.hasMesh; - logger.d('[USP][TopologyBuilder]: hasMesh=$hasMesh, ' - 'slaveNodes=${slaveNodes.length}, ' - 'slaveDeviceIds=${slaveNodes.map((n) => '${n.deviceId}|DE:${n.dataElementsId}').toList()}'); + // Build extender ID lookup maps. // Normalized set (no colons, uppercase) for matching against parentNodeId // which comes from DataElements clientToNodeMap (no colons). // We add BOTH deviceId (from Hosts) and dataElementsId (from DataElements) // since they may be different MAC addresses for the same node. + logger.t('[USP][TopologyBuilder]: hasMesh=${meshNetwork.hasMesh}, ' + 'slaveNodes=${meshNetwork.slaves.length}, ' + 'slaveDeviceIds=${meshNetwork.slaves.map((n) => '${n.deviceId}|DE:${n.dataElementsId}').toList()}'); final extenderNodeIdsNormalized = {}; - // Map from normalized ID back to original deviceId for building 'extender-X' IDs. final normalizedToOriginal = {}; - // Map from normalized Device ID to extender node ID (for parent resolution) final deviceIdToExtenderId = {}; - for (final slaveNode in slaveNodes) { - final extenderId = 'extender-${slaveNode.deviceId}'; - // Add Hosts MAC (deviceId) + for (final slave in meshNetwork.slaves) { + final extenderId = 'extender-${slave.deviceId}'; final normalizedHostsMac = - slaveNode.deviceId.toUpperCase().replaceAll(':', ''); + slave.deviceId.toUpperCase().replaceAll(':', ''); extenderNodeIdsNormalized.add(normalizedHostsMac); - normalizedToOriginal[normalizedHostsMac] = slaveNode.deviceId; + normalizedToOriginal[normalizedHostsMac] = slave.deviceId; deviceIdToExtenderId[normalizedHostsMac] = extenderId; - // Also add DataElements ID if different (may be a different interface MAC) - if (slaveNode.dataElementsId != null && - slaveNode.dataElementsId!.isNotEmpty) { + + if (slave.dataElementsId != null && slave.dataElementsId!.isNotEmpty) { final normalizedDeMac = - slaveNode.dataElementsId!.toUpperCase().replaceAll(':', ''); + slave.dataElementsId!.toUpperCase().replaceAll(':', ''); if (normalizedDeMac != normalizedHostsMac) { extenderNodeIdsNormalized.add(normalizedDeMac); - normalizedToOriginal[normalizedDeMac] = slaveNode.deviceId; + normalizedToOriginal[normalizedDeMac] = slave.deviceId; deviceIdToExtenderId[normalizedDeMac] = extenderId; } } - logger.d('[USP][TopologyBuilder]: Slave ${slaveNode.deviceId} ' + logger.t('[USP][TopologyBuilder]: Slave ${slave.deviceId} ' '→ hostsMac: $normalizedHostsMac, ' - 'dataElementsId: ${slaveNode.dataElementsId}, ' - 'backhaulParentDeviceId: ${slaveNode.backhaulParentDeviceId}'); + 'dataElementsId: ${slave.dataElementsId}, ' + 'backhaulParentDeviceId: ${slave.backhaul.parentNodeId}'); } - // Helper to resolve slave parent ID using backhaulParentDeviceId - String resolveSlaveParentId(NodeUIModel slaveNode) { - final parentDeviceId = slaveNode.backhaulParentDeviceId; - if (parentDeviceId == null || parentDeviceId.isEmpty) { - return gatewayId; - } - final normalizedParentId = - parentDeviceId.toUpperCase().replaceAll(':', ''); - return deviceIdToExtenderId[normalizedParentId] ?? gatewayId; - } + // Slave nodes + for (final slave in meshNetwork.slaves) { + final extenderId = 'extender-${slave.deviceId}'; - // Build extender nodes and links - for (final slaveNode in slaveNodes) { - final extenderId = 'extender-${slaveNode.deviceId}'; - final parentId = resolveSlaveParentId(slaveNode); + // Resolve parent + String parentId = gatewayId; + final parentDeviceId = slave.backhaul.parentNodeId; + if (parentDeviceId != null && parentDeviceId.isNotEmpty) { + final normalizedParentId = + parentDeviceId.toUpperCase().replaceAll(':', ''); + parentId = deviceIdToExtenderId[normalizedParentId] ?? gatewayId; + } - final extenderIconName = routerIconTestByModel( - modelNumber: slaveNode.model, - ); + final extenderIconName = routerIconTestByModel(modelNumber: slave.model); nodes.add(MeshNode( id: extenderId, - name: slaveNode.displayName, + name: slave.displayName, type: MeshNodeType.extender, status: MeshNodeStatus.online, parentId: parentId, image: DeviceImageHelper.getRouterImage(extenderIconName), - level: _backhaulRssiToLevel(slaveNode.backhaulSignalStrength), + level: _backhaulRssiToLevel(slave.backhaul.signalStrength), metadata: { - 'deviceId': slaveNode.deviceId, - 'model': slaveNode.model, - 'manufacturer': slaveNode.manufacturer, - 'serialNumber': slaveNode.serialNumber, - 'softwareVersion': slaveNode.softwareVersion, + 'deviceId': slave.deviceId, + 'model': slave.model, + 'manufacturer': slave.manufacturer, + 'serialNumber': slave.serialNumber, + 'softwareVersion': slave.softwareVersion, 'isMaster': false, - 'backhaulLinkType': slaveNode.backhaulLinkType, - 'backhaulParentDeviceId': slaveNode.backhaulParentDeviceId, - 'backhaulSignalStrength': slaveNode.backhaulSignalStrength, - 'backhaulUplinkRate': slaveNode.backhaulUplinkRate, - 'backhaulDownlinkRate': slaveNode.backhaulDownlinkRate, - 'lastContactTime': slaveNode.lastContactTime, + 'backhaulLinkType': slave.backhaul.linkType, + 'backhaulParentDeviceId': slave.backhaul.parentNodeId, + 'backhaulSignalStrength': slave.backhaul.signalStrength, + 'backhaulUplinkRate': slave.backhaul.uplinkRate, + 'backhaulDownlinkRate': slave.backhaul.downlinkRate, + 'lastContactTime': slave.backhaul.lastContactTime, }, )); links.add(MeshLink( sourceId: parentId, targetId: extenderId, - connectionType: slaveNode.backhaulLinkType == 'Ethernet' + connectionType: slave.backhaul.isEthernet ? ConnectionType.ethernet : ConnectionType.wifi, - rssi: slaveNode.backhaulSignalStrength, - linkQuality: _rssiToLinkQuality(slaveNode.backhaulSignalStrength), - throughput: slaveNode.backhaulUplinkRate != null - ? slaveNode.backhaulUplinkRate! / 1000.0 + rssi: slave.backhaul.signalStrength, + linkQuality: _rssiToLinkQuality(slave.backhaul.signalStrength), + throughput: slave.backhaul.uplinkRate != null + ? slave.backhaul.uplinkRate! / 1000.0 : null, )); } - // Client nodes from DeviceUIModel (excluding mesh nodes) - for (final device in devices) { - // Skip devices that are mesh nodes (master/slave) — already rendered - // as gateway or extenders. Only show "client" role devices. - if (device.isMeshNode) { - continue; - } - - final clientId = 'client-${device.mac}'; - final isEthernet = !device.isWifi; + // Client devices — use allClients which includes master + slave clients + for (final client in meshNetwork.allClients) { + final clientId = 'client-${client.mac}'; + final isEthernet = !client.isWifi; - // Determine parent: use parentNodeId from UI Model. - // parentNodeId comes from DataElements clientToNodeMap (no colons), - // so we normalize it before matching against extenderNodeIdsNormalized. + // Determine parent node String parentId = gatewayId; - if (hasMesh && device.parentNodeId != null) { + if (meshNetwork.hasMesh && client.parentNodeId != null) { final parentNormalized = - device.parentNodeId!.toUpperCase().replaceAll(':', ''); - logger.d('[USP][TopologyBuilder]: Device ${device.displayName} ' - 'parentNodeId=${device.parentNodeId}, ' + client.parentNodeId!.toUpperCase().replaceAll(':', ''); + logger.t('[USP][TopologyBuilder]: Device ${client.displayName} ' + 'parentNodeId=${client.parentNodeId}, ' 'normalized=$parentNormalized, ' 'inExtenders=${extenderNodeIdsNormalized.contains(parentNormalized)}'); if (extenderNodeIdsNormalized.contains(parentNormalized)) { @@ -177,32 +166,32 @@ class UspTopologyBuilder { parentId = 'extender-$originalDeviceId'; } } else { - logger.d('[USP][TopologyBuilder]: Device ${device.displayName} ' - 'hasMesh=$hasMesh, parentNodeId=${device.parentNodeId} → gateway'); + logger.t('[USP][TopologyBuilder]: Device ${client.displayName} ' + 'hasMesh=${meshNetwork.hasMesh}, ' + 'parentNodeId=${client.parentNodeId} → gateway'); } - // Classify device for icon final category = DeviceClassifier.classify( - hostname: device.displayName, - mac: device.mac, + hostname: client.displayName, + mac: client.mac, ); nodes.add(MeshNode( id: clientId, - name: device.displayName, + name: client.displayName, type: MeshNodeType.client, status: - device.isActive ? MeshNodeStatus.online : MeshNodeStatus.offline, + client.isOnline ? MeshNodeStatus.online : MeshNodeStatus.offline, parentId: parentId, iconData: category.icon, - extra: device.ip, - linkQuality: _resolveLinkQuality(device), - level: _rssiToLevel(device), + extra: client.ip, + linkQuality: _resolveLinkQualityForClient(client), + level: _rssiToLevelForClient(client), metadata: { - 'mac': device.mac, - 'hasMultipleInterfaces': device.hasMultipleInterfaces, - 'interfaceCount': device.interfaceCount, - 'allMacAddresses': device.allMacAddresses, + 'mac': client.mac, + 'hasMultipleInterfaces': client.hasMultipleInterfaces, + 'interfaceCount': client.interfaceCount, + 'allMacAddresses': client.allMacAddresses, }, )); @@ -211,13 +200,14 @@ class UspTopologyBuilder { targetId: clientId, connectionType: isEthernet ? ConnectionType.ethernet : ConnectionType.wifi, - rssi: device.signalStrength, + rssi: client.signalStrength, linkQuality: isEthernet ? LinkQuality.stable - : _rssiToLinkQuality(device.signalStrength), - throughput: - device.totalThroughput > 0 ? device.totalThroughput / 1000.0 : null, - distanceFactor: _rssiToDistanceFactor(device.signalStrength), + : _rssiToLinkQuality(client.signalStrength), + throughput: (client.downlinkRate ?? 0) + (client.uplinkRate ?? 0) > 0 + ? ((client.downlinkRate ?? 0) + (client.uplinkRate ?? 0)) / 1000.0 + : null, + distanceFactor: _rssiToDistanceFactor(client.signalStrength), )); } @@ -228,9 +218,14 @@ class UspTopologyBuilder { ); } - static double _rssiToLevel(DeviceUIModel device) { - if (!device.isWifi) return 1.0; - return _rssiValueToLevel(device.signalStrength); + static double _rssiToLevelForClient(ClientDevice client) { + if (!client.isWifi) return 1.0; + return _rssiValueToLevel(client.signalStrength); + } + + static LinkQuality _resolveLinkQualityForClient(ClientDevice client) { + if (!client.isWifi) return LinkQuality.stable; + return _rssiToLinkQuality(client.signalStrength); } /// Converts backhaul RSSI to level for extender nodes. @@ -253,42 +248,32 @@ class UspTopologyBuilder { }; } - static LinkQuality _resolveLinkQuality(DeviceUIModel device) { - if (!device.isWifi) return LinkQuality.stable; - return _rssiToLinkQuality(device.signalStrength); - } - /// Converts RSSI to LinkQuality using wifi.dart thresholds. /// + /// Maps [NodeSignalLevel] 1:1 to [LinkQuality] for consistency with + /// [UspSignalStrengthIndicator] and other signal displays. + /// /// Thresholds from [signalThresholdRSSI]: [-65, -71, -78] - /// - >= -65: excellent/strong - /// - >= -71: good/medium - /// - >= -78: fair/medium - /// - < -78: poor/weak + /// - >= -65: excellent + /// - >= -71: good + /// - >= -78: fair + /// - < -78: poor (unknown in LinkQuality) static LinkQuality _rssiToLinkQuality(int? rssi) { if (rssi == null) return LinkQuality.unknown; final level = getWifiSignalLevel(rssi); return switch (level) { NodeSignalLevel.excellent => LinkQuality.excellent, - NodeSignalLevel.good => LinkQuality.excellent, - NodeSignalLevel.fair => LinkQuality.good, - NodeSignalLevel.poor => LinkQuality.fair, + NodeSignalLevel.good => LinkQuality.good, + NodeSignalLevel.fair => LinkQuality.fair, + NodeSignalLevel.poor => LinkQuality.unknown, NodeSignalLevel.none => LinkQuality.unknown, NodeSignalLevel.wired => LinkQuality.stable, }; } /// Maps RSSI (dBm) to normalized distance factor [0.0, 1.0]. - /// - /// Aligned with [signalThresholdRSSI] from wifi.dart: [-65, -71, -78] - /// - >= -65 (excellent): close (0.0 - 0.25) - /// - >= -78 (fair): medium (0.25 - 0.75) - /// - < -78 (poor): far (0.75 - 1.0) - /// - /// Ethernet (null RSSI) → null (use default spacing). static double? _rssiToDistanceFactor(int? rssi) { if (rssi == null) return null; - // Range: -90 (far) to -50 (close) final clamped = rssi.clamp(-90, -50); return (clamped - (-50)).abs() / 40.0; } diff --git a/lib/page/topology/models/node_ui_model.dart b/lib/page/topology/models/node_ui_model.dart deleted file mode 100644 index c99f7e5a5..000000000 --- a/lib/page/topology/models/node_ui_model.dart +++ /dev/null @@ -1,134 +0,0 @@ -import 'package:equatable/equatable.dart'; - -/// Presentation Layer Model for a mesh node. -/// -/// UI widgets depend only on this class, never directly on codegen Data Models. -/// Naming follows constitution Section 3.3.4 (class name ends with `UIModel`). -/// Implements [Equatable] per Article XI. -class NodeUIModel extends Equatable { - final String deviceId; // MAC of the mesh node (from Hosts) - - // ─── DataElements ID (may differ from deviceId) ─── - final String? - dataElementsId; // node.id from DataElements (used in clientToNodeMap) - - // ─── Name fields (from Hosts) ─── - final String? friendlyName; // User-friendly name from Hosts - final String? hostName; // Hostname from Hosts - - final String model; // ManufacturerModel (e.g., MR7500) - final String manufacturer; - final String serialNumber; - final String softwareVersion; - final bool isMaster; // First node in DataElements = gateway - final int connectedDeviceCount; // Devices connected to this node - - // ─── Network addresses ─── - final String? ipAddress; // LAN IP address of the node - final List ipv6Addresses; // LAN IPv6 addresses of the node - final String? wanIpAddress; // WAN IP address (master only) - - // ─── Backhaul info (for child nodes) ─── - final String backhaulMediaType; // "IEEE 802.11ax" / "Ethernet" - final int backhaulPhyRate; // PHY rate in Mbps - final int? backhaulSignalStrength; // RSSI in dBm (converted from RCPI) - final int? backhaulUplinkRate; // kbps (from TR-181 LastDataUplinkRate) - - // ─── DataElements enrichment (Service internal use) ─── - final String? instancePath; // DataElements instance path - final String? backhaulAlId; // Parent node's AL ID (MAC) - final String? backhaulMacAddress; // Backhaul interface MAC - - // ─── Enhanced backhaul fields (from codegen) ─── - final String? backhaulLinkType; // "Wi-Fi" or "Ethernet" - final int? backhaulDownlinkRate; // kbps (from TR-181 LastDataDownlinkRate) - final String? backhaulParentDeviceId; // Parent node's Device ID - final String? backhaulParentBssid; // Connected BSSID - final String? lastContactTime; // ISO 8601 timestamp - - const NodeUIModel({ - required this.deviceId, - this.dataElementsId, - this.friendlyName, - this.hostName, - required this.model, - this.manufacturer = '', - this.serialNumber = '', - this.softwareVersion = '', - this.isMaster = false, - this.connectedDeviceCount = 0, - this.ipAddress, - this.ipv6Addresses = const [], - this.wanIpAddress, - this.backhaulMediaType = '', - this.backhaulPhyRate = 0, - this.backhaulSignalStrength, - this.backhaulUplinkRate, - this.instancePath, - this.backhaulAlId, - this.backhaulMacAddress, - this.backhaulLinkType, - this.backhaulDownlinkRate, - this.backhaulParentDeviceId, - this.backhaulParentBssid, - this.lastContactTime, - }); - - /// Display name priority: friendlyName > hostName > model > deviceId. - String get displayName { - if (friendlyName != null && friendlyName!.isNotEmpty) return friendlyName!; - if (hostName != null && hostName!.isNotEmpty) return hostName!; - if (model.isNotEmpty) return model; - return deviceId; - } - - /// Role label for UI display. - String get roleLabel => isMaster ? 'Master' : 'Slave'; - - /// Whether this node has backhaul info (i.e., it's a child node). - bool get hasBackhaul => backhaulMediaType.isNotEmpty; - - /// Whether backhaul connection is Ethernet (wired). - bool get isEthernetBackhaul => backhaulLinkType == 'Ethernet'; - - @override - List get props => [ - deviceId, - dataElementsId, - friendlyName, - hostName, - model, - manufacturer, - serialNumber, - softwareVersion, - isMaster, - connectedDeviceCount, - ipAddress, - ipv6Addresses, - wanIpAddress, - backhaulMediaType, - backhaulPhyRate, - backhaulSignalStrength, - backhaulUplinkRate, - instancePath, - backhaulAlId, - backhaulMacAddress, - backhaulLinkType, - backhaulDownlinkRate, - backhaulParentDeviceId, - backhaulParentBssid, - lastContactTime, - ]; -} - -/// Extension methods for List to simplify common filtering. -extension NodeUIModelListExt on List { - /// Returns the master (gateway) node, or null if not found. - NodeUIModel? get master => where((n) => n.isMaster).firstOrNull; - - /// Returns all slave (extender) nodes. - List get slaves => where((n) => !n.isMaster).toList(); - - /// Whether this topology has mesh extenders. - bool get hasMesh => slaves.isNotEmpty; -} diff --git a/lib/page/topology/providers/node_detail_provider.dart b/lib/page/topology/providers/node_detail_provider.dart index f4bb9693b..0ef467986 100644 --- a/lib/page/topology/providers/node_detail_provider.dart +++ b/lib/page/topology/providers/node_detail_provider.dart @@ -1,67 +1,67 @@ import 'package:equatable/equatable.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; -import 'package:privacy_gui/page/topology/models/node_ui_model.dart'; /// Computed provider — looks up a single node + its connected devices by deviceId. +/// +/// Uses [MeshNetwork] for direct node lookup and pre-organized connected clients. final uspNodeDetailProvider = Provider.family((ref, deviceId) { final data = ref.watch(devicesDataProvider).valueOrNull; - if (data == null) return UspNodeDetailState.empty(); + final meshNetwork = data?.meshNetwork; - final node = data.nodeModels - .where((n) => n.deviceId.toUpperCase() == deviceId.toUpperCase()) - .firstOrNull; + if (meshNetwork == null) return UspNodeDetailState.empty(); + final node = meshNetwork.findNode(deviceId); if (node == null) return UspNodeDetailState.empty(); - // Look up parent node using backhaulParentDeviceId (parent's Device ID) - NodeUIModel? parentNode; - if (node.backhaulParentDeviceId != null && - node.backhaulParentDeviceId!.isNotEmpty) { - final parentId = - node.backhaulParentDeviceId!.toUpperCase().replaceAll(':', ''); - parentNode = data.nodeModels.where((n) { - final nodeId = n.deviceId.toUpperCase().replaceAll(':', ''); - final nodeDeId = n.dataElementsId?.toUpperCase().replaceAll(':', ''); - return nodeId == parentId || nodeDeId == parentId; - }).firstOrNull; + // Look up parent node for slaves + NodeEntity? parentNode; + if (node is SlaveNode && node.backhaul.parentNodeId != null) { + parentNode = meshNetwork.findNode(node.backhaul.parentNodeId!); } - // For non-mesh routers the synthetic gateway uses deviceId 'gateway', - // and devices have parentNodeId == null (no DataElements mapping). - // Treat null parentNodeId as "connected to gateway". - final isGatewayLookup = deviceId.toUpperCase() == 'GATEWAY'; - final connectedDevices = data.deviceModels - .where((d) => - (d.parentNodeId != null && - d.parentNodeId!.toUpperCase() == deviceId.toUpperCase()) || - (isGatewayLookup && d.parentNodeId == null)) - .toList(); - return UspNodeDetailState( node: node, parentNode: parentNode, - connectedDevices: connectedDevices, + connectedClients: node.connectedClients, ); }); +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + class UspNodeDetailState extends Equatable { - final NodeUIModel? node; - final NodeUIModel? parentNode; - final List connectedDevices; + final NodeEntity? node; + final NodeEntity? parentNode; + final List connectedClients; const UspNodeDetailState({ this.node, this.parentNode, - this.connectedDevices = const [], + this.connectedClients = const [], }); factory UspNodeDetailState.empty() => const UspNodeDetailState(); - int get activeDeviceCount => connectedDevices.where((d) => d.isActive).length; + /// Whether data is available. + bool get hasData => node != null; + + /// Active (online) client count. + int get activeClientCount => connectedClients.where((c) => c.isOnline).length; + + /// Total connected client count. + int get totalClientCount => connectedClients.length; + + /// Node display name. + String get displayName => node?.displayName ?? ''; + + /// Whether this is the master node. + bool get isMaster => node?.isMaster ?? false; @override - List get props => [node, parentNode, connectedDevices]; + List get props => [node, parentNode, connectedClients]; } diff --git a/lib/page/topology/views/usp_node_detail_view.dart b/lib/page/topology/views/usp_node_detail_view.dart index dbdc07f28..20c50a022 100644 --- a/lib/page/topology/views/usp_node_detail_view.dart +++ b/lib/page/topology/views/usp_node_detail_view.dart @@ -5,14 +5,15 @@ import 'package:privacy_gui/localization/localization_hook.dart'; import 'package:privacy_gui/route/navigation_extensions.dart'; import 'package:privacy_gui/core/utils/device_image_helper.dart'; import 'package:privacy_gui/core/utils/icon_rules.dart'; +import 'package:privacy_gui/core/utils/ipv6_address.dart'; import 'package:privacy_gui/components/ui_kit_page_view.dart'; import 'package:privacy_gui/route/constants.dart'; import 'package:privacy_gui/page/_shared/components/detail_widgets.dart'; import 'package:privacy_gui/page/_shared/components/layout_blocks.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; import 'package:privacy_gui/page/devices/views/components/usp_device_list_tile.dart'; import 'package:privacy_gui/page/internet_settings/providers/wan_data_provider.dart'; import 'package:privacy_gui/page/shell/usp_top_bar.dart'; -import 'package:privacy_gui/page/topology/models/node_ui_model.dart'; import 'package:privacy_gui/page/topology/providers/node_detail_provider.dart'; import 'package:privacy_gui/page/topology/views/components/backhaul_signal_indicator.dart'; import 'package:privacy_gui/util/date_format_utils.dart'; @@ -71,13 +72,13 @@ class UspNodeDetailView extends ConsumerWidget { // =========================================================================== Widget _buildMobileLayout(BuildContext context, WidgetRef ref, - NodeUIModel node, UspNodeDetailState detail) { + NodeEntity node, UspNodeDetailState detail) { return Column( children: [ _buildNodeInfoCard(context, node), AppGap.lg(), _buildNetworkCard(context, ref, node), - if (!node.isMaster && node.hasBackhaul) ...[ + if (node is SlaveNode) ...[ AppGap.lg(), _buildBackhaulCard(context, node, detail.parentNode), ], @@ -88,7 +89,7 @@ class UspNodeDetailView extends ConsumerWidget { } Widget _buildDesktopLayout(BuildContext context, WidgetRef ref, - NodeUIModel node, UspNodeDetailState detail) { + NodeEntity node, UspNodeDetailState detail) { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -99,7 +100,7 @@ class UspNodeDetailView extends ConsumerWidget { _buildNodeInfoCard(context, node), AppGap.lg(), _buildNetworkCard(context, ref, node), - if (!node.isMaster && node.hasBackhaul) ...[ + if (node is SlaveNode) ...[ AppGap.lg(), _buildBackhaulCard(context, node, detail.parentNode), ], @@ -119,7 +120,7 @@ class UspNodeDetailView extends ConsumerWidget { // Node Info Card // =========================================================================== - Widget _buildNodeInfoCard(BuildContext context, NodeUIModel node) { + Widget _buildNodeInfoCard(BuildContext context, NodeEntity node) { final colorScheme = Theme.of(context).colorScheme; return AppCard( padding: const EdgeInsets.all(AppSpacing.md), @@ -154,7 +155,9 @@ class UspNodeDetailView extends ConsumerWidget { AppGap.xs(), DetailStatusBadge( isActive: true, - activeLabel: node.roleLabel, + activeLabel: node.isMaster + ? loc(context).master + : loc(context).slave, ), ], ), @@ -207,7 +210,7 @@ class UspNodeDetailView extends ConsumerWidget { // =========================================================================== Widget _buildNetworkCard( - BuildContext context, WidgetRef ref, NodeUIModel node) { + BuildContext context, WidgetRef ref, NodeEntity node) { final wanData = node.isMaster ? ref.watch(wanDataProvider).valueOrNull?.model : null; final wanIp = wanData?.ipAddress; @@ -232,12 +235,17 @@ class UspNodeDetailView extends ConsumerWidget { label: loc(context).lanIp, value: node.ipAddress!, ), - // LAN IPv6 (from Hosts) - for (final ipv6 in node.ipv6Addresses) + // LAN IPv6 (from Hosts) — routable addresses first; a link-local + // address swaps its leading icon for a scope badge (see + // #1128/#1129). + for (final ipv6 in preferGlobalIpv6First(node.ipv6Addresses)) DetailCopyableTile( icon: Icons.language, label: loc(context).lanIpv6, value: ipv6, + leading: isLinkLocalIpv6(ipv6) + ? const Ipv6ScopeBadge(size: 16) + : null, ), // WAN IPv4 (master only) if (wanIp != null && wanIp.isNotEmpty) @@ -246,12 +254,17 @@ class UspNodeDetailView extends ConsumerWidget { label: loc(context).wanIp, value: wanIp, ), - // WAN IPv6 (master only) - for (final ipv6 in wanIpv6Addresses) + // WAN IPv6 (master only) — routable addresses first; a link-local + // address swaps its leading icon for a scope badge (see + // #1128/#1129). + for (final ipv6 in preferGlobalIpv6First(wanIpv6Addresses)) DetailCopyableTile( icon: Icons.public, label: loc(context).wanIpv6, value: ipv6, + leading: isLinkLocalIpv6(ipv6) + ? const Ipv6ScopeBadge(size: 16) + : null, ), ], ), @@ -265,9 +278,10 @@ class UspNodeDetailView extends ConsumerWidget { // =========================================================================== Widget _buildBackhaulCard( - BuildContext context, NodeUIModel node, NodeUIModel? parentNode) { + BuildContext context, SlaveNode node, NodeEntity? parentNode) { final colorScheme = Theme.of(context).colorScheme; - final isWifiBackhaul = !node.isEthernetBackhaul; + final backhaul = node.backhaul; + final isWifiBackhaul = !backhaul.isEthernet; return AppCard( padding: const EdgeInsets.all(AppSpacing.md), @@ -299,7 +313,7 @@ class UspNodeDetailView extends ConsumerWidget { AppText.labelSmall(loc(context).connectedTo, color: colorScheme.onSurfaceVariant), AppText.bodyMedium( - '${parentNode.roleLabel} (${parentNode.model})'), + '${parentNode.isMaster ? loc(context).master : loc(context).slave} (${parentNode.model})'), ], ), ), @@ -331,16 +345,16 @@ class UspNodeDetailView extends ConsumerWidget { ), AppGap.xs(), AppText.bodyMedium( - node.backhaulLinkType ?? node.backhaulMediaType), + backhaul.linkType ?? backhaul.mediaType), ], ), ), ), - if (node.backhaulSignalStrength != null) ...[ + if (backhaul.signalStrength != null) ...[ AppGap.sm(), Expanded( - child: BackhaulSignalIndicator( - rssi: node.backhaulSignalStrength!), + child: + BackhaulSignalIndicator(rssi: backhaul.signalStrength!), ), ], ], @@ -363,36 +377,34 @@ class UspNodeDetailView extends ConsumerWidget { ], ), AppGap.xs(), - AppText.bodyMedium( - node.backhaulLinkType ?? node.backhaulMediaType), + AppText.bodyMedium(backhaul.linkType ?? backhaul.mediaType), ], ), ), AppGap.sm(), ], // Throughput Block - if (node.backhaulUplinkRate != null || - node.backhaulDownlinkRate != null) ...[ + if (backhaul.uplinkRate != null || backhaul.downlinkRate != null) ...[ Row( children: [ - if (node.backhaulUplinkRate != null) + if (backhaul.uplinkRate != null) Expanded( child: DetailSpeedCard( icon: Icons.upload, label: loc(context).upload, - speedKbps: node.backhaulUplinkRate!, + speedKbps: backhaul.uplinkRate!, color: colorScheme.tertiary, ), ), - if (node.backhaulUplinkRate != null && - node.backhaulDownlinkRate != null) + if (backhaul.uplinkRate != null && + backhaul.downlinkRate != null) AppGap.sm(), - if (node.backhaulDownlinkRate != null) + if (backhaul.downlinkRate != null) Expanded( child: DetailSpeedCard( icon: Icons.download, label: loc(context).download, - speedKbps: node.backhaulDownlinkRate!, + speedKbps: backhaul.downlinkRate!, color: colorScheme.primary, ), ), @@ -401,10 +413,10 @@ class UspNodeDetailView extends ConsumerWidget { AppGap.sm(), ], // PHY Rate + Last Contact row - if (node.backhaulPhyRate > 0 || node.lastContactTime != null) + if (backhaul.phyRate > 0 || backhaul.lastContactTime != null) Row( children: [ - if (node.backhaulPhyRate > 0) + if (backhaul.phyRate > 0) Expanded( child: LayoutBlock( padding: const EdgeInsets.all(AppSpacing.md), @@ -423,14 +435,14 @@ class UspNodeDetailView extends ConsumerWidget { ), AppGap.xs(), AppText.bodyMedium(NetworkUtils.formatSpeed( - node.backhaulPhyRate * 1000)), + backhaul.phyRate * 1000)), ], ), ), ), - if (node.backhaulPhyRate > 0 && node.lastContactTime != null) + if (backhaul.phyRate > 0 && backhaul.lastContactTime != null) AppGap.sm(), - if (node.lastContactTime != null) + if (backhaul.lastContactTime != null) Expanded( child: LayoutBlock( padding: const EdgeInsets.all(AppSpacing.md), @@ -449,7 +461,7 @@ class UspNodeDetailView extends ConsumerWidget { ), AppGap.xs(), AppText.bodyMedium(DateFormatUtils.formatRelativeTime( - node.lastContactTime)), + backhaul.lastContactTime)), ], ), ), @@ -467,8 +479,8 @@ class UspNodeDetailView extends ConsumerWidget { Widget _buildConnectedDevicesCard( BuildContext context, UspNodeDetailState detail) { - final devices = detail.connectedDevices; - final activeCount = detail.activeDeviceCount; + final devices = detail.connectedClients; + final activeCount = detail.activeClientCount; return AppCard( padding: const EdgeInsets.all(AppSpacing.md), @@ -500,7 +512,7 @@ class UspNodeDetailView extends ConsumerWidget { child: UspDeviceListTile( device: devices[i], variant: DeviceListTileVariant.flatLast, - onTap: () => context.goNamed( + onTap: () => context.pushNamed( RouteNamed.uspDeviceDetail, queryParameters: {'mac': devices[i].mac}, ), diff --git a/lib/page/topology/views/usp_topology_view.dart b/lib/page/topology/views/usp_topology_view.dart index f759cf145..eb3a2ff31 100644 --- a/lib/page/topology/views/usp_topology_view.dart +++ b/lib/page/topology/views/usp_topology_view.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:privacy_gui/components/ui_kit_page_view.dart'; +import 'package:privacy_gui/components/views/service_error_view.dart'; +import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/localization/localization_hook.dart'; import 'package:privacy_gui/route/constants.dart'; import 'package:privacy_gui/page/admin/providers/system_info_data_provider.dart'; @@ -44,38 +46,29 @@ class _UspTopologyViewState extends ConsumerState { child: (childContext, constraints) { return asyncDevices.when( loading: () => const Center(child: AppLoader()), - error: (error, _) => Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - AppText.titleMedium(loc(context).unableToLoadTopology), - AppGap.md(), - AppButton.text( - label: loc(context).retry, - onTap: () => ref.invalidate(devicesDataProvider), - ), - ], - ), + error: (error, _) => ServiceErrorView( + error: error is ServiceError ? error : null, + title: loc(context).unableToLoadTopology, + onRetry: () => ref.invalidate(devicesDataProvider), ), data: (data) { final sysInfo = ref.read(systemInfoDataProvider).valueOrNull?.model; - if (sysInfo == null) return const SizedBox.shrink(); - final topology = UspTopologyBuilder.build( + if (sysInfo == null) { + return const SizedBox.shrink(); + } + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: data.meshNetwork, info: sysInfo, - devices: data.deviceModels, - nodeModels: data.nodeModels, ); - return _buildTopologyCard( - context, topology, data.deviceModels.length); + return _buildTopologyCard(context, topology); }, ); }, ); } - Widget _buildTopologyCard( - BuildContext context, MeshTopology topology, int deviceCount) { + Widget _buildTopologyCard(BuildContext context, MeshTopology topology) { final router = GoRouter.of(context); final colorScheme = Theme.of(context).colorScheme; diff --git a/lib/page/unified_diagnostics/cards/usp_speed_test_card.dart b/lib/page/unified_diagnostics/cards/usp_speed_test_card.dart index 2e2f7a867..565dabed9 100644 --- a/lib/page/unified_diagnostics/cards/usp_speed_test_card.dart +++ b/lib/page/unified_diagnostics/cards/usp_speed_test_card.dart @@ -27,13 +27,17 @@ class UspSpeedTestCard extends ConsumerWidget { detailRoute: RouteNamed.uspSpeedTest, content: asyncState.when( loading: () => const Center(child: AppLoader()), - error: (_, __) => _buildError(context, ref), + error: (error, _) => _buildError(context, ref, error), data: (state) => _buildBody(context, ref, state, colorScheme), ), ); } - Widget _buildError(BuildContext context, WidgetRef ref) { + /// Compact error state for the build() failure. Kept card-sized (not the + /// full-page [ServiceErrorView]) to fit the constrained DashboardCardTemplate + /// height — mirrors [_buildErrorState] below. Localizes via + /// [localizeServiceError]. + Widget _buildError(BuildContext context, WidgetRef ref, Object error) { return Center( child: Column( mainAxisSize: MainAxisSize.min, @@ -41,7 +45,13 @@ class UspSpeedTestCard extends ConsumerWidget { AppIcon.font(Icons.error_outline, size: 32, color: Theme.of(context).colorScheme.error), AppGap.sm(), - AppText.bodySmall(loc(context).errorLoadingSpeedTest), + AppText.bodySmall( + localizeServiceError(context, error), + textAlign: TextAlign.center, + color: Theme.of(context).colorScheme.onSurfaceVariant, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), AppGap.md(), AppButton.text( label: loc(context).retry, diff --git a/lib/page/unified_diagnostics/views/speed_test_view.dart b/lib/page/unified_diagnostics/views/speed_test_view.dart index c70c59da7..b12ad0085 100644 --- a/lib/page/unified_diagnostics/views/speed_test_view.dart +++ b/lib/page/unified_diagnostics/views/speed_test_view.dart @@ -4,6 +4,8 @@ import 'package:go_router/go_router.dart'; import 'package:privacy_gui/components/localizations/service_error_localizations.dart'; import 'package:privacy_gui/components/shortcuts/dialogs.dart'; import 'package:privacy_gui/components/ui_kit_page_view.dart'; +import 'package:privacy_gui/components/views/service_error_view.dart'; +import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/localization/localization_hook.dart'; import 'package:privacy_gui/page/_shared/components/layout_blocks.dart'; import 'package:privacy_gui/route/constants.dart'; @@ -32,34 +34,17 @@ class SpeedTestView extends ConsumerWidget { child: (childContext, constraints) { return asyncState.when( loading: () => const Center(child: AppLoader()), - error: (error, _) => _buildPageError(context, ref, error), + error: (error, _) => ServiceErrorView( + error: error is ServiceError ? error : null, + title: loc(context).unableToLoadSpeedTest, + onRetry: () => ref.invalidate(speedTestProvider), + ), data: (state) => _buildContent(context, ref, state), ); }, ); } - Widget _buildPageError(BuildContext context, WidgetRef ref, Object error) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - AppIcon.font(Icons.error_outline, - size: 48, color: Theme.of(context).colorScheme.error), - AppGap.xl(), - AppText.titleMedium(loc(context).unableToLoadSpeedTest), - AppGap.md(), - AppText.bodyMedium(error.toString()), - AppGap.xxl(), - AppButton( - label: loc(context).retry, - onTap: () => ref.invalidate(speedTestProvider), - ), - ], - ), - ); - } - Widget _buildContent( BuildContext context, WidgetRef ref, SpeedTestState state) { return switch (state.step) { diff --git a/lib/page/unified_diagnostics/views/unified_diagnostics_view.dart b/lib/page/unified_diagnostics/views/unified_diagnostics_view.dart index 0bb24d962..054f938df 100644 --- a/lib/page/unified_diagnostics/views/unified_diagnostics_view.dart +++ b/lib/page/unified_diagnostics/views/unified_diagnostics_view.dart @@ -114,7 +114,7 @@ class _UnifiedDiagnosticsViewState AppGap.xxxl(), AppButton( label: loc(context).returnToDashboard, - onTap: () => _returnToDashboard(context, ref), + onTap: () => _returnToMenu(context, ref), ), ], ), @@ -129,12 +129,16 @@ class _UnifiedDiagnosticsViewState final notifier = ref.read(unifiedDiagnosticsProvider.notifier); final handledInternally = notifier.goBack(); if (!handledInternally) { - _returnToDashboard(context, ref); + _returnToMenu(context, ref); } } - void _returnToDashboard(BuildContext context, WidgetRef ref) { + void _returnToMenu(BuildContext context, WidgetRef ref) { ref.read(unifiedDiagnosticsProvider.notifier).cancel(); - context.goNamed(RouteNamed.uspDashboard); + if (context.canPop()) { + context.pop(); + } else { + context.goNamed(RouteNamed.uspMenu); + } } } diff --git a/lib/page/unified_diagnostics/views/widgets/diagnostic_manual_tools_view.dart b/lib/page/unified_diagnostics/views/widgets/diagnostic_manual_tools_view.dart index 6ac7a78cb..56955c096 100644 --- a/lib/page/unified_diagnostics/views/widgets/diagnostic_manual_tools_view.dart +++ b/lib/page/unified_diagnostics/views/widgets/diagnostic_manual_tools_view.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:privacy_gui/components/localizations/service_error_localizations.dart'; +import 'package:privacy_gui/components/views/service_error_view.dart'; +import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/core/usp/models/operate_result.dart'; import 'package:privacy_gui/localization/localization_hook.dart'; import 'package:privacy_gui/page/_shared/components/layout_blocks.dart'; @@ -49,29 +51,12 @@ class _DiagnosticManualToolsViewState return asyncState.when( loading: () => const Center(child: AppLoader()), - error: (error, _) => _buildPageError(context, error), - data: (state) => _buildContent(context, state), - ); - } - - Widget _buildPageError(BuildContext context, Object error) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - AppIcon.font(Icons.error_outline, - size: 48, color: Theme.of(context).colorScheme.error), - AppGap.xl(), - AppText.titleMedium(loc(context).unableToLoadDiagnostics), - AppGap.md(), - AppText.bodyMedium(error.toString()), - AppGap.xxl(), - AppButton( - label: loc(context).retry, - onTap: () => ref.invalidate(manualToolsProvider), - ), - ], + error: (error, _) => ServiceErrorView( + error: error is ServiceError ? error : null, + title: loc(context).unableToLoadDiagnostics, + onRetry: () => ref.invalidate(manualToolsProvider), ), + data: (state) => _buildContent(context, state), ); } diff --git a/lib/page/wifi_settings/cards/usp_wifi_networks_card.dart b/lib/page/wifi_settings/cards/usp_wifi_networks_card.dart index 6770b755d..f0060c4db 100644 --- a/lib/page/wifi_settings/cards/usp_wifi_networks_card.dart +++ b/lib/page/wifi_settings/cards/usp_wifi_networks_card.dart @@ -7,6 +7,7 @@ import 'package:privacy_gui/page/_shared/models/client_connection_detail.dart'; import 'package:privacy_gui/page/_shared/models/wifi_radio_ui_model.dart'; import 'package:privacy_gui/page/_shared/components/card_skeleton.dart'; import 'package:privacy_gui/page/_shared/components/dashboard_card_template.dart'; +import 'package:privacy_gui/page/_shared/components/layout_blocks.dart'; import 'package:privacy_gui/page/_shared/components/usp_mutation_helper.dart'; import 'package:privacy_gui/page/wifi_settings/providers/usp_wifi_settings_provider.dart'; import 'package:privacy_gui/page/wifi_settings/providers/wifi_data_provider.dart'; @@ -53,6 +54,7 @@ class UspWifiNetworksCard extends ConsumerWidget { final data = wifiData ?? ref.watch(wifiDataProvider).valueOrNull; if (data == null) return const CardSkeleton.list(rows: 3); + final isLoading = ref.watch(uspMutationLoadingProvider) == 'wifi_network'; final networks = _aggregateBySSID( data.radioModels, data.connectionDetailMap, @@ -68,7 +70,7 @@ class UspWifiNetworksCard extends ConsumerWidget { : Column( children: [ for (var i = 0; i < networks.length; i++) ...[ - _buildNetworkRow(context, ref, networks[i]), + _buildNetworkRow(context, ref, networks[i], isLoading), if (i < networks.length - 1) AppGap.sm(), ], ], @@ -92,160 +94,20 @@ class UspWifiNetworksCard extends ConsumerWidget { BuildContext context, WidgetRef ref, _WifiNetworkEntry network, + bool isLoading, ) { - final scheme = Theme.of(context).colorScheme; - final isLoading = ref.watch(uspMutationLoadingProvider) == 'wifi_network'; - - return Container( - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: scheme.surfaceContainerHighest.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(AppSpacing.sm), - border: Border.all( - color: network.isGuest - ? scheme.secondary.withValues(alpha: 0.3) - : scheme.outline.withValues(alpha: 0.2), - ), - ), - child: Row( - children: [ - // Network info - Expanded( - child: Opacity( - opacity: network.isEnabled ? 1.0 : 0.5, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // SSID name with guest indicator - Row( - children: [ - Flexible( - child: AppText.bodyLarge( - network.ssidName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - if (network.isGuest) ...[ - AppGap.sm(), - _buildGuestBadge(context), - ], - ], - ), - AppGap.xs(), - // Band badges and client count - Row( - children: [ - ...network.bands.map((band) => Padding( - padding: - const EdgeInsets.only(right: AppSpacing.xs), - child: _buildBandBadge(context, band), - )), - AppGap.sm(), - Icon( - Icons.devices, - size: 14, - color: scheme.onSurfaceVariant, - ), - AppGap.xxs(), - AppText.labelSmall( - '${network.clientCount}', - color: scheme.onSurfaceVariant, - ), - ], - ), - ], - ), - ), - ), - // QR / Share button - if (network.isEnabled && onShareTap != null) ...[ - _buildShareButton(context, network.ssidName), - AppGap.sm(), - ], - // Enable/Disable toggle - AppSwitch( - value: network.isEnabled, - onChanged: isLoading - ? null - : (value) => - _confirmToggleNetwork(context, ref, network, value), - ), - ], - ), - ); - } - - Widget _buildBandBadge(BuildContext context, String band) { - final scheme = Theme.of(context).colorScheme; - - final (color, label) = switch (band.toLowerCase()) { - String b when b.contains('2.4') => (const Color(0xFF4A9EFF), '2.4G'), - String b when b.contains('5') && !b.contains('6') => ( - const Color(0xFF4ADE80), - '5G' - ), - String b when b.contains('6') => (const Color(0xFFA78BFA), '6G'), - _ => (scheme.outline, band), - }; - - return Container( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.sm, - vertical: 2, - ), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(AppSpacing.xs), - ), - child: AppText.labelSmall( - label, - color: color, - ), - ); - } - - Widget _buildGuestBadge(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - - return Container( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.sm, - vertical: 2, - ), - decoration: BoxDecoration( - color: scheme.secondary.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(AppSpacing.xs), - ), - child: AppText.labelSmall( - 'Guest', - color: scheme.secondary, - ), - ); - } - - Widget _buildShareButton(BuildContext context, String ssid) { - final scheme = Theme.of(context).colorScheme; - - return Material( - color: Colors.transparent, - child: InkWell( - onTap: () => onShareTap?.call(ssid), - borderRadius: BorderRadius.circular(AppSpacing.sm), - child: Container( - padding: const EdgeInsets.all(AppSpacing.sm), - decoration: BoxDecoration( - color: scheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(AppSpacing.sm), - border: Border.all(color: scheme.outline.withValues(alpha: 0.3)), - ), - child: Icon( - Icons.qr_code_2, - size: 24, - color: scheme.onSurface, - ), - ), - ), + return NetworkRow( + ssidName: network.ssidName, + bands: network.bands, + isGuest: network.isGuest, + isEnabled: network.isEnabled, + clientCount: network.clientCount, + isLoading: isLoading, + onChanged: isLoading + ? null + : (value) => _confirmToggleNetwork(context, ref, network, value), + onShareTap: + onShareTap != null ? () => onShareTap!(network.ssidName) : null, ); } diff --git a/lib/page/wifi_settings/cards/usp_wifi_performance_card.dart b/lib/page/wifi_settings/cards/usp_wifi_performance_card.dart index c86df64d0..036188739 100644 --- a/lib/page/wifi_settings/cards/usp_wifi_performance_card.dart +++ b/lib/page/wifi_settings/cards/usp_wifi_performance_card.dart @@ -4,13 +4,11 @@ import 'package:privacy_gui/localization/localization_hook.dart'; import 'package:privacy_gui/page/_shared/components/card_skeleton.dart'; import 'package:privacy_gui/page/_shared/components/layout_blocks.dart'; import 'package:privacy_gui/page/_shared/components/wifi_ui.dart'; -import 'package:privacy_gui/page/_shared/models/wifi_client_ui_model.dart'; import 'package:privacy_gui/page/_shared/models/wifi_radio_ui_model.dart'; import 'package:privacy_gui/page/_shared/providers/card_tab_state_provider.dart'; import 'package:privacy_gui/page/_shared/components/dashboard_card_template.dart'; import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; import 'package:privacy_gui/page/wifi_settings/providers/wifi_data_provider.dart'; -import 'package:privacy_gui/route/constants.dart'; import 'package:ui_kit_library/ui_kit.dart'; /// WiFi Performance Analytics card — 3-tab view. @@ -26,27 +24,43 @@ class UspWifiPerformanceCard extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final wifiData = ref.watch(wifiDataProvider).valueOrNull; - if (wifiData == null) return const CardSkeleton.chart(); final devicesData = ref.watch(devicesDataProvider).valueOrNull; + // Wait for both providers to load — devicesData contains meshNetwork with + // slave node clients, wifiData contains radioModels for Channels tab + if (wifiData == null || devicesData == null) { + return const CardSkeleton.chart(); + } final selectedTab = ref.watch(cardTabIndexProvider(_cardId)); - // Collect active WiFi clients with their device names and band info + // Collect backhaul MACs from slave nodes to filter out mesh node STAs + final backhaulMacs = {}; + for (final slave in devicesData.meshNetwork.slaves) { + final mac = slave.backhaul.backhaulMacAddress; + if (mac != null && mac.isNotEmpty) { + backhaulMacs.add(mac.toUpperCase()); + } + } + + // Collect active WiFi clients from MeshNetwork (includes slave node clients) + // Also enrich with master-only data (noise, rates) from wifiClientMap final activeClients = <_ClientInfo>[]; - for (final entry in wifiData.wifiClientMap.entries) { - final client = entry.value; - if (!client.active) continue; - // Resolve display name from deviceModels - final device = devicesData?.deviceModels - .where((d) => d.mac.toUpperCase() == entry.key.toUpperCase()) - .firstOrNull; - final displayName = device?.hostName ?? entry.key; - // Resolve band from connectionDetailMap (AP → SSID → Radio chain) - final detail = wifiData.connectionDetailMap[entry.key]; + final allWifiClients = devicesData.meshNetwork.allClients + .where((c) => c.isWifi && c.isActive) + .where((c) => !backhaulMacs.contains(c.mac.toUpperCase())) + .toList(); + + for (final client in allWifiClients) { + // Get additional data from wifiClientMap (only available for master clients) + final masterData = wifiData.wifiClientMap[client.mac.toUpperCase()]; activeClients.add(_ClientInfo( - mac: entry.key, - displayName: displayName, - client: client, - band: detail?.band ?? '', + mac: client.mac, + displayName: client.displayName, + signalStrength: client.signalStrength ?? -100, + noise: masterData?.noise ?? 0, + downlinkRate: + client.downlinkRate ?? masterData?.lastDataDownlinkRate ?? 0, + uplinkRate: client.uplinkRate ?? masterData?.lastDataUplinkRate ?? 0, + band: client.band ?? '', )); } @@ -54,7 +68,6 @@ class UspWifiPerformanceCard extends ConsumerWidget { title: loc(context).wifiPerformance, titleBadge: AppBadge(label: loc(context).clientsCount(activeClients.length)), - detailRoute: RouteNamed.uspWifiSettings, tabs: [ CardTab( label: loc(context).signal, @@ -86,15 +99,24 @@ class UspWifiPerformanceCard extends ConsumerWidget { class _ClientInfo { final String mac; final String displayName; - final WifiClientUIModel client; + final int signalStrength; + final int noise; + final int downlinkRate; // kbps + final int uplinkRate; // kbps final String band; // "2.4GHz", "5GHz", "6GHz", or "" const _ClientInfo({ required this.mac, required this.displayName, - required this.client, + required this.signalStrength, + this.noise = 0, + this.downlinkRate = 0, + this.uplinkRate = 0, this.band = '', }); + + /// Whether this client has rate data (master node clients have it, slaves don't). + bool get hasRateData => downlinkRate > 0 || uplinkRate > 0; } // ============================================================================= @@ -127,7 +149,7 @@ class _SignalTab extends StatelessWidget { separatorBuilder: (_, __) => AppGap.sm(), itemBuilder: (context, index) { final c = clients[index]; - final rssi = c.client.signalStrength; + final rssi = c.signalStrength; final tier = getSignalTier(rssi); final color = tier.resolveColor(colorScheme); // Normalize: -100 dBm → 0.0, -30 dBm → 1.0 @@ -201,11 +223,17 @@ class _SpeedTab extends StatelessWidget { final List<_ClientInfo> clients; const _SpeedTab({required this.clients}); + static String _truncateName(String name, [int maxLen = 10]) => + name.length > maxLen ? '${name.substring(0, maxLen)}…' : name; + @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - if (clients.isEmpty) { + // Filter to only clients with rate data (master node clients) + final clientsWithRates = clients.where((c) => c.hasRateData).toList(); + + if (clientsWithRates.isEmpty) { return Center( child: AppText.bodyMedium( 'No WiFi clients connected', @@ -215,11 +243,10 @@ class _SpeedTab extends StatelessWidget { } // Convert kbps to Mbps for chart display - final dlData = - clients.map((c) => c.client.lastDataDownlinkRate / 1000).toList(); - final ulData = - clients.map((c) => c.client.lastDataUplinkRate / 1000).toList(); - final xLabels = clients.map((c) => c.displayName).toList(); + final dlData = clientsWithRates.map((c) => c.downlinkRate / 1000).toList(); + final ulData = clientsWithRates.map((c) => c.uplinkRate / 1000).toList(); + final xLabels = + clientsWithRates.map((c) => _truncateName(c.displayName)).toList(); return Column( children: [ @@ -239,7 +266,7 @@ class _SpeedTab extends StatelessWidget { ], xLabels: xLabels, yLabelFormatter: (v) => '${v.toInt()} Mbps', - showValueLabels: clients.length <= 4, + showValueLabels: clientsWithRates.length <= 4, valueLabelFormatter: (v) => '${v.toInt()}', showTooltip: false, ), @@ -317,9 +344,16 @@ class _ChannelsTab extends StatelessWidget { final radioIdx = bandToRadioIdx[c.band]; if (radioIdx == null) continue; clientsPerRadio[radioIdx] = (clientsPerRadio[radioIdx] ?? 0) + 1; - final snr = computeSNR(c.client.signalStrength, c.client.noise); - snrSumPerRadio[radioIdx] = (snrSumPerRadio[radioIdx] ?? 0) + snr; - snrCountPerRadio[radioIdx] = (snrCountPerRadio[radioIdx] ?? 0) + 1; + // Only clients with real noise data contribute to the average SNR. + // Slave-node clients have no noise (they aren't in wifiClientMap), so + // computeSNR returns 0; including them would deflate the per-radio + // average once #1118 gives them a resolved band. Count them as clients + // but exclude them from the SNR aggregation until noise is available. + if (c.noise != 0) { + final snr = computeSNR(c.signalStrength, c.noise); + snrSumPerRadio[radioIdx] = (snrSumPerRadio[radioIdx] ?? 0) + snr; + snrCountPerRadio[radioIdx] = (snrCountPerRadio[radioIdx] ?? 0) + 1; + } } // Compute average SNR per radio diff --git a/lib/page/wifi_settings/cards/usp_wifi_status_card.dart b/lib/page/wifi_settings/cards/usp_wifi_status_card.dart index 24cf5b61f..d701cfa6a 100644 --- a/lib/page/wifi_settings/cards/usp_wifi_status_card.dart +++ b/lib/page/wifi_settings/cards/usp_wifi_status_card.dart @@ -58,20 +58,6 @@ class UspWifiStatusCard extends ConsumerWidget { UspStatusDot(isActive: radio.enable), AppGap.sm(), AppText.labelLarge(loc(context).radioBand(radio.band)), - const Spacer(), - AppSwitch( - value: radio.enable, - onChanged: isLoading - ? null - : (value) => performUspMutation( - context, - ref, - loadingKey: 'wifi', - mutation: () => ref - .read(uspWifiSettingsProvider.notifier) - .toggleRadio(radio.instancePath, value), - ), - ), ], ), AppGap.sm(), @@ -173,7 +159,7 @@ class UspWifiStatusCard extends ConsumerWidget { channel: result.channel, autoChannel: result.autoChannel, ), - successMessage: 'Channel updated', + successMessage: loc(context).channelUpdated, ); } diff --git a/lib/page/wifi_settings/models/wifi_network_ui_model.dart b/lib/page/wifi_settings/models/wifi_network_ui_model.dart index e9e06fb40..3ab9b0051 100644 --- a/lib/page/wifi_settings/models/wifi_network_ui_model.dart +++ b/lib/page/wifi_settings/models/wifi_network_ui_model.dart @@ -101,11 +101,10 @@ class WifiNetworkUIModel extends Equatable { return band.isNotEmpty ? band : 'Unknown'; } - /// True if this network uses an open (no password) security mode + /// True if this network uses an open (no password) security mode. + /// 'OWE' is the TR-181 token for Enhanced Open (firmware only accepts 'OWE'). bool get isOpenSecurity => - securityMode == 'None' || - securityMode.isEmpty || - securityMode == 'Enhanced-Open'; + securityMode == 'None' || securityMode.isEmpty || securityMode == 'OWE'; /// Channel display string ("Auto" if autoChannelEnable, else the channel number) String get channelDisplay => autoChannelEnable ? 'Auto' : channel.toString(); diff --git a/lib/page/wifi_settings/models/wifi_settings_settings.dart b/lib/page/wifi_settings/models/wifi_settings_settings.dart index 7ed6bacc7..1a0f634fb 100644 --- a/lib/page/wifi_settings/models/wifi_settings_settings.dart +++ b/lib/page/wifi_settings/models/wifi_settings_settings.dart @@ -52,7 +52,8 @@ class WifiQuickSetupSettings extends Equatable { final passwordChanged = original == null || password != original.password; final modeChanged = original == null || securityMode != original.securityMode; - final isOpen = securityMode == 'None' || securityMode == 'Enhanced-Open'; + // 'OWE' is the TR-181 token for Enhanced Open (firmware only accepts 'OWE'). + final isOpen = securityMode == 'None' || securityMode == 'OWE'; return (passwordChanged || modeChanged) && !isOpen; } diff --git a/lib/page/wifi_settings/providers/usp_wifi_advanced_provider.dart b/lib/page/wifi_settings/providers/usp_wifi_advanced_provider.dart index 16a3db638..c562b0bc9 100644 --- a/lib/page/wifi_settings/providers/usp_wifi_advanced_provider.dart +++ b/lib/page/wifi_settings/providers/usp_wifi_advanced_provider.dart @@ -1,6 +1,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/core/utils/logger.dart'; +import 'package:privacy_gui/core/utils/tr181_path.dart'; +import 'package:privacy_gui/core/utils/wifi_channel.dart'; import 'package:privacy_gui/core/usp/providers/usp_mutation_lock.dart'; import 'package:privacy_gui/framework/preservable_contract.dart'; import 'package:privacy_gui/framework/preservable_notifier_mixin.dart'; @@ -106,15 +108,39 @@ class UspWifiAdvancedNotifier final radioPaths = current.ieee80211hByRadio.keys.toList(); final enabled = current.isDfsEnabled; + // When DFS is being disabled, any radio parked on a DFS channel must be + // moved off it — the firmware leaves the channel set on its own + // (SSH-verified). Force AutoChannelEnable on those radios so the firmware + // reselects a legal non-DFS channel. Only radios being turned off and + // currently sitting on a manual DFS channel are affected. + final forceAutoChannelPaths = []; + if (!enabled) { + final radios = ref.read(wifiDataProvider).valueOrNull?.radioModels ?? []; + final radioByPath = { + for (final r in radios) ensureTrailingDot(r.instancePath): r, + }; + for (final path in radioPaths) { + // Radios staying on DFS need no channel remediation. + if (current.ieee80211hByRadio[path] == true) continue; + final radio = radioByPath[ensureTrailingDot(path)]; + if (radio == null || radio.autoChannelEnable) continue; + if (isDfsChannel(radio.channel, band: radio.band)) { + forceAutoChannelPaths.add(path); + } + } + } + await ref.read(uspMutationLockProvider).withLock(() async { await _svc.setIeee80211hEnabled( radioPaths: radioPaths, enabled: enabled, + forceAutoChannelPaths: forceAutoChannelPaths, ); }); logger.d('[USP][WiFi][Advanced]: Save succeeded — ' - 'radios=${radioPaths.length}, enabled=$enabled'); + 'radios=${radioPaths.length}, enabled=$enabled, ' + 'forcedAutoChannel=${forceAutoChannelPaths.length}'); // Refresh Layer 1 cache so post-save fetch() reads fresh data. // Using refresh() instead of invalidate() because the latter only marks // the provider dirty — without an active subscriber it won't rebuild, diff --git a/lib/page/wifi_settings/providers/usp_wifi_settings_provider.dart b/lib/page/wifi_settings/providers/usp_wifi_settings_provider.dart index 0e38dce87..cbe1e5313 100644 --- a/lib/page/wifi_settings/providers/usp_wifi_settings_provider.dart +++ b/lib/page/wifi_settings/providers/usp_wifi_settings_provider.dart @@ -75,15 +75,12 @@ class UspWifiSettingsNotifier extends AutoDisposeNotifier ); } - if (!usp.isAuthenticated) { - return ( - null, - WifiSettingsStatus( - error: const NotAuthenticatedError(detail: 'USP not authenticated'), - ) - ); - } - + // No auth gate here: the router already guards all /usp routes on + // loginType (RA-aware), and the WiFi data layer surfaces real errors. The + // raw usp.isAuthenticated flag is a WASM transport signal that stays false + // in Remote Assistance (authToken bypass), so gating on it here wrongly + // blocked RA sessions (issue #1119). The usp == null gate above still + // covers the "USP unavailable" case. logger.d('[USP][WiFi]: Fetching WiFi data...'); // Read from WiFi Data Provider (Layer 1) to avoid duplicate fetch. @@ -175,26 +172,30 @@ class UspWifiSettingsNotifier extends AutoDisposeNotifier @override Future performSave() async { - await ref.read(uspMutationLockProvider).withLock(() async { - final current = state.settings.current; - if (current.quickSetupEnabled) { - await _svc.saveQuickSetup( - original: state.settings.original, - current: current, - status: state.status, - ); - } else { - await _svc.saveAdvanced( - original: state.settings.original.networks, - current: current.networks, - ); - } - }); - // Refresh Layer 1 cache so post-save fetch() reads fresh data. - // Using refresh() instead of invalidate() because the latter only marks - // the provider dirty — without an active subscriber it won't rebuild, - // and the subsequent .future call would return stale data. - final _ = await ref.refresh(wifiDataProvider.future); + try { + await ref.read(uspMutationLockProvider).withLock(() async { + final current = state.settings.current; + if (current.quickSetupEnabled) { + await _svc.saveQuickSetup( + original: state.settings.original, + current: current, + status: state.status, + ); + } else { + await _svc.saveAdvanced( + original: state.settings.original.networks, + current: current.networks, + ); + } + }); + } finally { + // Refresh Layer 1 cache so post-save fetch() reads fresh data. + // Using refresh() instead of invalidate() because the latter only marks + // the provider dirty — without an active subscriber it won't rebuild, + // and the subsequent .future call would return stale data. + // Wrapped in finally to ensure UI stays in sync even on partial failure. + final _ = await ref.refresh(wifiDataProvider.future); + } } // --------------------------------------------------------------------------- @@ -325,19 +326,6 @@ class UspWifiSettingsNotifier extends AutoDisposeNotifier // Dashboard quick actions — delegate to Service, then invalidate L1 // --------------------------------------------------------------------------- - /// Toggles a WiFi radio on/off. Called from Dashboard card. - Future toggleRadio(String instancePath, bool enable) async { - try { - await ref.read(uspMutationLockProvider).withLock(() async { - await _svc.toggleRadio(instancePath, enable); - }); - } on ServiceError catch (e) { - logger.e('[USP][WiFi]: Toggle radio failed', error: e); - rethrow; - } - ref.invalidate(wifiDataProvider); - } - /// Updates a WiFi radio's channel. Called from Dashboard card. Future updateRadioChannel( String instancePath, { @@ -355,19 +343,24 @@ class UspWifiSettingsNotifier extends AutoDisposeNotifier } on ServiceError catch (e) { logger.e('[USP][WiFi]: Update radio channel failed', error: e); rethrow; + } finally { + ref.invalidate(wifiDataProvider); } - ref.invalidate(wifiDataProvider); } /// Toggles all SSIDs with a given name on/off across all bands. /// Called from Dashboard WiFi Networks card. Future toggleSsidsByName(String ssidName, bool enable) async { - final wifiData = await ref.read(wifiDataProvider.future); - final ssids = wifiData.codegenContext.raw.ssids; - try { final count = await ref.read(uspMutationLockProvider).withLock(() async { - return _svc.toggleSsidsByName(ssids, ssidName, enable); + // Read wifiData inside lock to avoid TOCTOU race with concurrent mutations + final wifiData = await ref.read(wifiDataProvider.future); + return _svc.toggleSsidsByName( + wifiData.codegenContext.raw.ssids, + wifiData.codegenContext.raw.accessPoints, + ssidName, + enable, + ); }); if (count == 0) { logger.w('[USP][WiFi]: No SSIDs found matching the requested name'); @@ -378,8 +371,9 @@ class UspWifiSettingsNotifier extends AutoDisposeNotifier } on ServiceError catch (e) { logger.e('[USP][WiFi]: Toggle SSIDs by name failed', error: e); rethrow; + } finally { + ref.invalidate(wifiDataProvider); } - ref.invalidate(wifiDataProvider); } // --------------------------------------------------------------------------- diff --git a/lib/page/wifi_settings/providers/wifi_data_provider.dart b/lib/page/wifi_settings/providers/wifi_data_provider.dart index 7c3775d9b..b12b7bf9f 100644 --- a/lib/page/wifi_settings/providers/wifi_data_provider.dart +++ b/lib/page/wifi_settings/providers/wifi_data_provider.dart @@ -2,8 +2,8 @@ import 'dart:async'; import 'package:equatable/equatable.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:privacy_gui/core/utils/logger.dart'; import 'package:privacy_gui/core/usp/providers/sse_invalidation_provider.dart'; +import 'package:privacy_gui/framework/diagnostic_loggable.dart'; import 'package:privacy_gui/page/_shared/models/wifi_client_ui_model.dart'; import 'package:privacy_gui/page/_shared/models/wifi_radio_ui_model.dart'; import 'package:privacy_gui/page/_shared/models/client_connection_detail.dart'; @@ -17,7 +17,7 @@ export 'package:privacy_gui/page/wifi_settings/services/usp_wifi_data_service.da // Data Model (Layer 1 — UIModel only) // --------------------------------------------------------------------------- -class WifiData extends Equatable { +class WifiData extends Equatable with DiagnosticLoggable { /// Opaque codegen context for WiFi settings service consumption. final WifiCodegenContext codegenContext; @@ -41,12 +41,24 @@ class WifiData extends Equatable { connectionDetailMap = const {}, radioModels = const []; + @override + String get diagnosticName => 'WifiData'; + + @override + Map get namedProps => { + 'wifiClientMap': wifiClientMap, + 'connectionDetailMap': connectionDetailMap, + 'radioModels': radioModels, + }; + + // Explicit props override for reliable equality comparison (includes codegenContext). + // namedProps is kept lean for diagnostic JSON output. @override List get props => [ codegenContext, - wifiClientMap.length, - connectionDetailMap.length, - radioModels.length, + wifiClientMap, + connectionDetailMap, + radioModels, ]; } @@ -86,10 +98,6 @@ class WifiDataNotifier extends AsyncNotifier { final svc = ref.read(uspWifiDataServiceProvider); final result = await svc.fetch(); - logger.d('[USP][WifiData]: Fetched — ' - 'clients: ${result.wifiClientMap.length}, ' - 'radios: ${result.radioModels.length}'); - return WifiData( codegenContext: result.codegenContext, wifiClientMap: result.wifiClientMap, diff --git a/lib/page/wifi_settings/services/usp_wifi_advanced_service.dart b/lib/page/wifi_settings/services/usp_wifi_advanced_service.dart index 8fb8bbd16..ac59f22b1 100644 --- a/lib/page/wifi_settings/services/usp_wifi_advanced_service.dart +++ b/lib/page/wifi_settings/services/usp_wifi_advanced_service.dart @@ -1,4 +1,5 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/core/usp/errors/usp_error.dart'; import 'package:privacy_gui/core/usp/providers/usp_client_provider.dart'; import 'package:privacy_gui/core/usp/services/usp_client.dart'; @@ -43,17 +44,47 @@ class UspWifiAdvancedService { } /// Sets IEEE 802.11h on all given radio paths. + /// + /// [forceAutoChannelPaths] additionally receives `AutoChannelEnable = true` + /// in the same set() call. This is used when disabling DFS on a radio that is + /// parked on a DFS channel: the firmware does not vacate the channel on its + /// own (SSH-verified), so forcing auto-channel makes it reselect a legal + /// non-DFS channel. Paths not in this list keep their channel settings. Future setIeee80211hEnabled({ required List radioPaths, required bool enabled, + List forceAutoChannelPaths = const [], }) async { if (radioPaths.isEmpty) return; try { final params = { for (final path in radioPaths) '${path}IEEE80211hEnabled': enabled, + for (final path in forceAutoChannelPaths) + '${path}AutoChannelEnable': true, }; - await _usp.set(params); + final result = await _usp.set(params); + // Parse the batch result so a firmware partial rejection (e.g. accepts + // IEEE80211hEnabled but rejects a forced AutoChannelEnable) surfaces as an + // error instead of being silently swallowed. + final parsed = UspResultParser.parseSetResult(result); + switch (parsed) { + case UspSuccess(): + break; + case UspPartialSuccess(failures: final f): + throw UspPartialFailureError( + summary: + 'IEEE80211h update partial failure: ${f.first.errorMessage}', + successPaths: const [], + failures: f, + ); + case UspFailure(errors: final e): + throw UspCompleteFailureError( + summary: 'IEEE80211h update failed: ${e.first.errorMessage}', + failures: e, + ); + } } catch (e) { + if (e is ServiceError) rethrow; throw mapUspErrorToServiceError(e); } } diff --git a/lib/page/wifi_settings/services/usp_wifi_data_service.dart b/lib/page/wifi_settings/services/usp_wifi_data_service.dart index 6e9fdb4ac..05ea60f67 100644 --- a/lib/page/wifi_settings/services/usp_wifi_data_service.dart +++ b/lib/page/wifi_settings/services/usp_wifi_data_service.dart @@ -9,7 +9,10 @@ import 'package:privacy_gui/generated/wifi_clients.g.dart'; import 'package:privacy_gui/core/usp/providers/usp_client_provider.dart'; import 'package:privacy_gui/core/usp/services/usp_client.dart'; import 'package:privacy_gui/core/utils/logger.dart'; +import 'package:privacy_gui/core/utils/tr181_path.dart'; +import 'package:privacy_gui/core/utils/wifi_channel.dart'; import 'package:privacy_gui/page/_shared/models/client_connection_detail.dart'; +import 'package:privacy_gui/page/_shared/utils/wifi_guest_detection.dart'; import 'package:privacy_gui/page/_shared/models/wifi_client_ui_model.dart'; import 'package:privacy_gui/page/_shared/models/wifi_radio_ui_model.dart'; @@ -157,61 +160,57 @@ class UspWifiDataService { required WiFiAccessPoints accessPoints, }) { final ssidByPath = { - for (final s in ssids.items) _ensureTrailingDot(s.instancePath): s, + for (final s in ssids.items) ensureTrailingDot(s.instancePath): s, }; - // Determine guest SSIDs: per-radio, lowest instance index is Main - final guestSsidPaths = {}; - { - final ssidsByRadio = >{}; - for (final ssid in ssids.items) { - final radioKey = _ensureTrailingDot(ssid.lowerLayers); - (ssidsByRadio[radioKey] ??= []).add(ssid); - } - logger.d('[USP][WiFi] ssidsByRadio groups: ${ssidsByRadio.length}'); - for (final entry in ssidsByRadio.entries) { - final group = entry.value; - group.sort((a, b) => _ssidInstanceIndex(a.instancePath) - .compareTo(_ssidInstanceIndex(b.instancePath))); - logger.d('[USP][WiFi] Radio ${entry.key}: ' - '${group.map((s) => "${s.ssid}(${s.instancePath})").join(", ")}'); - for (final ssid in group.skip(1)) { - guestSsidPaths.add(_ensureTrailingDot(ssid.instancePath)); - logger.d( - '[USP][WiFi] Marked as guest: ${ssid.ssid} (${ssid.instancePath})'); - } - } + // Determine guest SSIDs via the canonical alias rule (see + // wifi_guest_detection). Single source of truth shared across the app. + final guestSsidPaths = { + for (final ssid in ssids.items) + if (isGuestSsid(ssid)) ensureTrailingDot(ssid.instancePath), + }; + logger.t('[USP][WiFi] Total guest SSID paths: ${guestSsidPaths.length}'); + // Diagnostic: multiple SSIDs but none matched the `-guest` alias rule + // usually means firmware did not provision guest aliases (see + // wifi_guest_detection). Guest/main grouping degrades silently otherwise. + if (guestSsidPaths.isEmpty && ssids.items.length > 1) { + logger.w('[USP][WiFi] No SSID matched the "-guest" alias rule; ' + 'guest networks will be treated as main. Aliases: ' + '${ssids.items.map((s) => s.alias ?? "null").toList()}'); } - logger.d('[USP][WiFi] Total guest SSID paths: ${guestSsidPaths.length}'); // Group APs by radio: AP.ssidReference → SSID.lowerLayers → Radio final apsByRadioPath = >{}; for (final ap in accessPoints.items) { - final ssid = ssidByPath[_ensureTrailingDot(ap.ssidReference)]; + final ssid = ssidByPath[ensureTrailingDot(ap.ssidReference)]; if (ssid == null) continue; - final radioPath = _ensureTrailingDot(ssid.lowerLayers); + final radioPath = ensureTrailingDot(ssid.lowerLayers); apsByRadioPath.putIfAbsent(radioPath, () => []).add((ap: ap, ssid: ssid)); } return radios.items.map((radio) { final radioAps = - apsByRadioPath[_ensureTrailingDot(radio.instancePath)] ?? []; + apsByRadioPath[ensureTrailingDot(radio.instancePath)] ?? []; final apModels = radioAps.map((a) { final isGuest = - guestSsidPaths.contains(_ensureTrailingDot(a.ssid.instancePath)); - // Use SSID.enable as the canonical enabled state (matches toggle mutation) + guestSsidPaths.contains(ensureTrailingDot(a.ssid.instancePath)); + // Per-network enabled state = SSID.Enable. The Dashboard toggle mutates + // both SSID.Enable and AccessPoint.Enable together, so either would do; + // we read SSID.Enable as the single source of truth for the UI. return WifiAccessPointUIModel( enable: a.ssid.enable, ssidName: a.ssid.ssid.isNotEmpty ? a.ssid.ssid : a.ap.ssidReference, securityMode: a.ap.securityModeEnabled, encryptionMode: a.ap.encryptionMode, isGuest: isGuest, + accessPointInstancePath: a.ap.instancePath, + ssidInstancePath: a.ssid.instancePath, ); }).toList(); return WifiRadioUIModel( instancePath: radio.instancePath, - band: radio.operatingFrequencyBand, + band: _normalizeBand(radio.operatingFrequencyBand), enable: radio.enable, transmitPower: radio.transmitPower, maxBitRate: radio.maxBitRate, @@ -219,17 +218,13 @@ class UspWifiDataService { autoChannelEnable: radio.autoChannelEnable, channelBandwidth: radio.operatingChannelBandwidth, supportedStandards: radio.supportedStandards, + possibleChannels: parsePossibleChannels(radio.possibleChannels), + isDfsEnabled: radio.ieee80211hEnabled, accessPoints: apModels, ); }).toList(); } - /// Extracts the numeric instance index from a TR-181 SSID path. - int _ssidInstanceIndex(String instancePath) { - final match = RegExp(r'Device\.WiFi\.SSID\.(\d+)').firstMatch(instancePath); - return match != null ? int.parse(match.group(1)!) : 0; - } - // --------------------------------------------------------------------------- // WiFi Clients // --------------------------------------------------------------------------- @@ -240,7 +235,6 @@ class UspWifiDataService { /// limitation), falls back to a broader parent-path fetch and manual parse. Future> _fetchWifiClients() async { final result = await WifiClients.fetch(_usp); - logger.d('[USP][Dashboard]: WifiClients raw: ${result.items.length} items'); if (result.items.isNotEmpty) { return { @@ -249,17 +243,10 @@ class UspWifiDataService { }; } - logger.d( - '[USP][Dashboard]WifiClients selective-get empty, trying parent-path fallback'); try { - final fallback = await _fetchWifiClientsFallback(); - if (fallback.isNotEmpty) { - logger.d( - '[USP][Dashboard]WifiClients fallback: ${fallback.length} clients'); - } - return fallback; + return await _fetchWifiClientsFallback(); } catch (e) { - logger.d('[USP][Dashboard]: WifiClients fallback failed: $e'); + logger.w('[WiFi] Fallback fetch failed: $e'); return {}; } } @@ -342,37 +329,34 @@ class UspWifiDataService { }) { final apByPath = { for (final ap in accessPoints.items) - _ensureTrailingDot(ap.instancePath): ap, + ensureTrailingDot(ap.instancePath): ap, }; final ssidByPath = { - for (final s in ssids.items) _ensureTrailingDot(s.instancePath): s, + for (final s in ssids.items) ensureTrailingDot(s.instancePath): s, }; final bandByRadioPath = { for (final r in radios.items) - _ensureTrailingDot(r.instancePath): + ensureTrailingDot(r.instancePath): _normalizeBand(r.operatingFrequencyBand), }; - logger.d('[USP][Dashboard]: Connection detail: ' - '${apByPath.length} APs, ${ssidByPath.length} SSIDs, ${bandByRadioPath.length} radios'); - final result = {}; for (final entry in wifiClientMap.entries) { final mac = entry.key; final client = entry.value; - final ap = apByPath[_ensureTrailingDot(client.parentPath)]; + final ap = apByPath[ensureTrailingDot(client.parentPath)]; if (ap == null) { logger.d( '[USP][Dashboard]Connection detail: no AP for parentPath=${client.parentPath}'); continue; } - final ssid = ssidByPath[_ensureTrailingDot(ap.ssidReference)]; + final ssid = ssidByPath[ensureTrailingDot(ap.ssidReference)]; final ssidName = ssid?.ssid ?? ''; final band = ssid != null - ? (bandByRadioPath[_ensureTrailingDot(ssid.lowerLayers)] ?? '') + ? (bandByRadioPath[ensureTrailingDot(ssid.lowerLayers)] ?? '') : ''; result[mac] = ClientConnectionDetail(band: band, ssidName: ssidName); @@ -404,11 +388,6 @@ class UspWifiDataService { // Helpers // --------------------------------------------------------------------------- - static String _ensureTrailingDot(String path) { - if (path.isEmpty) return path; - return path.endsWith('.') ? path : '$path.'; - } - static String _normalizeBand(String rawBand) { final lower = rawBand.toLowerCase(); if (lower.contains('6g') || lower.contains('6 g')) return '6GHz'; @@ -416,4 +395,36 @@ class UspWifiDataService { if (lower.contains('2.4') || lower.contains('2_4')) return '2.4GHz'; return rawBand; } + + /// Builds a BSSID → band mapping from WiFi SSID and Radio data. + /// + /// Used by [MeshTopologyBuilder] to determine band for clients on slave nodes + /// (via DataElements BSS.BSSID → this map → band). + /// + /// The mapping is: SSID.BSSID + SSID.LowerLayers → Radio.OperatingFrequencyBand + static Map buildBssidToBandMap({ + required WiFiSsids ssids, + required WiFiRadios radios, + }) { + // Build Radio path → band lookup + final bandByRadioPath = {}; + for (final radio in radios.items) { + final path = ensureTrailingDot(radio.instancePath); + bandByRadioPath[path] = _normalizeBand(radio.operatingFrequencyBand); + } + + // Build BSSID → band mapping via SSID.LowerLayers → Radio + final result = {}; + for (final ssid in ssids.items) { + final bssid = ssid.bssid.trim().toUpperCase(); + if (bssid.isEmpty) continue; + + final radioPath = ensureTrailingDot(ssid.lowerLayers); + final band = bandByRadioPath[radioPath]; + if (band != null && band.isNotEmpty) { + result[bssid] = band; + } + } + return result; + } } diff --git a/lib/page/wifi_settings/services/usp_wifi_settings_service.dart b/lib/page/wifi_settings/services/usp_wifi_settings_service.dart index 2f50e5eca..40373131a 100644 --- a/lib/page/wifi_settings/services/usp_wifi_settings_service.dart +++ b/lib/page/wifi_settings/services/usp_wifi_settings_service.dart @@ -1,6 +1,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/core/utils/logger.dart'; +import 'package:privacy_gui/core/utils/tr181_path.dart'; +import 'package:privacy_gui/core/utils/wifi_channel.dart'; import 'package:privacy_gui/core/usp/errors/usp_error.dart'; import 'package:privacy_gui/generated/wi_fi_access_points.g.dart'; import 'package:privacy_gui/generated/wi_fi_radios.g.dart'; @@ -12,6 +14,7 @@ import 'package:privacy_gui/page/wifi_settings/models/wifi_quick_setup_network.d import 'package:privacy_gui/page/wifi_settings/models/wifi_settings_settings.dart'; import 'package:privacy_gui/page/wifi_settings/models/wifi_settings_status.dart'; import 'package:privacy_gui/page/wifi_settings/services/wifi_channel_bonding.dart'; +import 'package:privacy_gui/page/_shared/utils/wifi_guest_detection.dart'; final uspWifiSettingsServiceProvider = Provider( (ref) => UspWifiSettingsService(ref.read(uspClientProvider)!), @@ -44,13 +47,13 @@ class UspWifiSettingsService { // Build lookup maps with normalized trailing-dot paths final apBySsidRef = {}; for (final ap in accessPoints.items) { - final key = _ensureTrailingDot(ap.ssidReference); + final key = ensureTrailingDot(ap.ssidReference); if (key.isNotEmpty) apBySsidRef[key] = ap; } final radioByPath = {}; for (final r in radios.items) { - radioByPath[_ensureTrailingDot(r.instancePath)] = r; + radioByPath[ensureTrailingDot(r.instancePath)] = r; } logger.d('[USP][WiFi]: Building networks: ' @@ -60,13 +63,13 @@ class UspWifiSettingsService { final networks = []; for (final ssid in ssids.items) { - final ssidPath = _ensureTrailingDot(ssid.instancePath); + final ssidPath = ensureTrailingDot(ssid.instancePath); // Find matching AccessPoint via ssidReference final ap = apBySsidRef[ssidPath]; // Find matching Radio via SSID.lowerLayers - final radioPath = _ensureTrailingDot(ssid.lowerLayers); + final radioPath = ensureTrailingDot(ssid.lowerLayers); final radio = radioByPath[radioPath]; logger.d('[USP][WiFi]: SSID ${ssid.ssid}: ' @@ -74,16 +77,24 @@ class UspWifiSettingsService { 'radio=${radio?.operatingFrequencyBand ?? "none"}, ' 'alias=${ssid.alias ?? "none"}'); - // Guest detection via alias (FW 1.2.1+ auto-provisions wifi-*-guest aliases) - final isGuest = ssid.alias?.endsWith('-guest') ?? false; + // Guest detection via the canonical alias rule (see wifi_guest_detection). + final isGuest = isGuestSsid(ssid); // Parse Security.ModesSupported comma-separated string into a list. // e.g. "None, WPA2-Personal, WPA3-Personal" → ['None', 'WPA2-Personal', 'WPA3-Personal'] final supportedModes = _parseModesSupported(ap?.modesSupported ?? ''); final band = _normalizeBand(radio?.operatingFrequencyBand ?? ''); - final possibleChannels = - _parsePossibleChannels(radio?.possibleChannels ?? ''); + // DFS (IEEE 802.11h) channels must not appear when DFS is disabled. The + // firmware leaves them in PossibleChannels regardless, so filter here — + // before computing per-bandwidth lists — so both the dropdown and the + // "N channels available" counts stay consistent. + final dfsEnabled = radio?.ieee80211hEnabled ?? false; + final possibleChannels = filterDfsChannels( + parsePossibleChannels(radio?.possibleChannels ?? ''), + band: band, + dfsEnabled: dfsEnabled, + ); final supportedBandwidths = _parseSupportedBandwidths( radio?.supportedOperatingChannelBandwidths ?? ''); @@ -273,13 +284,16 @@ class UspWifiSettingsService { } } - // ── AP layer — only when password or securityMode changed ────────── + // ── AP layer — when password, securityMode, or enabled changed ───── + // enabled is mirrored onto AccessPoint.Enable alongside SSID.Enable + // because SSID.Enable alone does not stop broadcasting on this + // firmware (see #972). final passwordChanged = orig == null || orig.password != pending.password; final modeChanged = orig == null || orig.securityMode != pending.securityMode; if (aggregate.apInstancePaths.isNotEmpty && - (passwordChanged || modeChanged)) { + (passwordChanged || modeChanged || enabledChanged)) { // Build a band lookup: AP instance path → band string. // Used to apply the 6 GHz security override (Wi-Fi 6E mandates WPA3). final bandByApPath = { @@ -288,6 +302,11 @@ class UspWifiSettingsService { n.accessPointInstancePath!: n.band, }; + // Whether the security layer (mode + passphrase) needs writing. + // When only `enabled` changed we mirror AccessPoint.Enable without + // re-sending security params, so an enable toggle never mutates the + // security mode (e.g. the 6 GHz WPA3 override). + final securityChanged = passwordChanged || modeChanged; for (final p in aggregate.apInstancePaths) { final band = bandByApPath[p] ?? ''; final securityMode = _securityModeFor6GHz( @@ -299,11 +318,13 @@ class UspWifiSettingsService { [ WiFiAccessPointUpdate( instancePath: p, + enable: enabledChanged ? pending.enabled : null, // Omit an empty passphrase (e.g. when only securityMode // changed to an open mode) so firmware does not reject it. - keyPassphrase: - pending.password.isNotEmpty ? pending.password : null, - securityModeEnabled: securityMode, + keyPassphrase: securityChanged && pending.password.isNotEmpty + ? pending.password + : null, + securityModeEnabled: securityChanged ? securityMode : null, ) ], ); @@ -384,23 +405,44 @@ class UspWifiSettingsService { } // ── AccessPoint layer ─────────────────────────────────────────────── + // Mirror the enabled flag onto AccessPoint.Enable alongside SSID.Enable: + // on this firmware SSID.Enable alone does not stop the AP broadcasting, + // so the enable state must be written to both layers (see #972). + // + // Each field is gated on its own diff so a pure enable toggle sends + // only AccessPoint.Enable and never re-writes the security mode or the + // advertisement flag (mirrors saveQuickSetup's AP gating). final ap = curr.accessPointInstancePath; + final enabledChanged = orig == null || orig.enabled != curr.enabled; + final securityChanged = orig == null || + orig.keyPassphrase != curr.keyPassphrase || + orig.securityMode != curr.securityMode; + final broadcastChanged = orig == null || + orig.ssidAdvertisementEnabled != curr.ssidAdvertisementEnabled; if (ap != null && - (orig == null || - orig.keyPassphrase != curr.keyPassphrase || - orig.securityMode != curr.securityMode || - orig.ssidAdvertisementEnabled != - curr.ssidAdvertisementEnabled)) { + (enabledChanged || securityChanged || broadcastChanged)) { + // Apply the 6 GHz security override (Wi-Fi 6E mandates WPA3), the + // same way saveQuickSetup does, so both save paths write a + // firmware-valid mode on 6 GHz. Skip the override for enable-only + // toggles (securityChanged false) so the mode is never re-written. + final securityMode = securityChanged && curr.securityMode.isNotEmpty + ? _securityModeFor6GHz( + band: curr.band, + selectedMode: curr.securityMode, + ) + : null; final result = await WiFiAccessPoints.update( _usp, [ WiFiAccessPointUpdate( instancePath: ap, - keyPassphrase: - curr.keyPassphrase.isNotEmpty ? curr.keyPassphrase : null, - securityModeEnabled: - curr.securityMode.isNotEmpty ? curr.securityMode : null, - ssidAdvertisementEnabled: curr.ssidAdvertisementEnabled, + enable: enabledChanged ? curr.enabled : null, + keyPassphrase: securityChanged && curr.keyPassphrase.isNotEmpty + ? curr.keyPassphrase + : null, + securityModeEnabled: securityMode, + ssidAdvertisementEnabled: + broadcastChanged ? curr.ssidAdvertisementEnabled : null, ) ], ); @@ -476,35 +518,6 @@ class UspWifiSettingsService { // Mutations — WiFi Radio quick actions (from Dashboard cards) // --------------------------------------------------------------------------- - /// Toggles a WiFi radio on or off. - Future toggleRadio(String instancePath, bool enable) async { - try { - final result = await WiFiRadios.update( - _usp, - [WiFiRadioUpdate(instancePath: instancePath, enable: enable)], - ); - final parsed = UspResultParser.parseSetResult(result); - switch (parsed) { - case UspSuccess(): - break; - case UspPartialSuccess(failures: final f): - throw UspPartialFailureError( - summary: 'Toggle radio partial failure: ${f.first.errorMessage}', - successPaths: [], - failures: f, - ); - case UspFailure(errors: final e): - throw UspCompleteFailureError( - summary: 'Toggle radio failed: ${e.first.errorMessage}', - failures: e, - ); - } - } catch (e) { - if (e is ServiceError) rethrow; - throw mapUspErrorToServiceError(e); - } - } - /// Updates a WiFi radio's channel and auto-channel setting. Future updateRadioChannel( String instancePath, { @@ -545,63 +558,94 @@ class UspWifiSettingsService { } } - /// Toggles all SSIDs with a given name on or off across all bands. + /// Toggles all networks with a given SSID name on or off across all bands. + /// + /// Finds all SSID instances matching [ssidName] and toggles both their + /// SSID.Enable and the matching AccessPoint.Enable. Writing both layers is + /// required because SSID.Enable alone does not stop the AP broadcasting on + /// this firmware (see #972). The AccessPoint match is resolved via + /// AccessPoint.SSIDReference → SSID.instancePath. /// - /// Finds all SSID instances matching [ssidName] from [ssids] and toggles them. /// Returns the number of SSIDs toggled. Future toggleSsidsByName( WiFiSsids ssids, + WiFiAccessPoints accessPoints, String ssidName, bool enable, ) async { - final instancePaths = ssids.items + final ssidPaths = ssids.items .where((s) => s.ssid == ssidName) .map((s) => s.instancePath) .toList(); - if (instancePaths.isEmpty) return 0; + if (ssidPaths.isEmpty) return 0; + + // Resolve AccessPoint paths whose SSIDReference points at a matched SSID. + final matchedSsidPathSet = ssidPaths.map(ensureTrailingDot).toSet(); + final apPaths = accessPoints.items + .where((ap) => + matchedSsidPathSet.contains(ensureTrailingDot(ap.ssidReference))) + .map((ap) => ap.instancePath) + .toList(); try { - final updates = instancePaths + final ssidUpdates = ssidPaths .map((p) => WiFiSsidUpdate(instancePath: p, enable: enable)) .toList(); - final result = await WiFiSsids.update(_usp, updates); - final parsed = UspResultParser.parseSetResult(result); - switch (parsed) { - case UspSuccess(): - return instancePaths.length; - case UspPartialSuccess(failures: final f): - throw UspPartialFailureError( - summary: 'Toggle SSIDs partial failure: ${f.first.errorMessage}', - successPaths: [], - failures: f, - ); - case UspFailure(errors: final e): - throw UspCompleteFailureError( - summary: 'Toggle SSIDs failed: ${e.first.errorMessage}', - failures: e, - ); + final ssidResult = await WiFiSsids.update(_usp, ssidUpdates); + _throwIfNotSuccess(ssidResult, 'Toggle SSIDs'); + + if (apPaths.isNotEmpty) { + final apUpdates = apPaths + .map((p) => WiFiAccessPointUpdate(instancePath: p, enable: enable)) + .toList(); + final apResult = await WiFiAccessPoints.update(_usp, apUpdates); + _throwIfNotSuccess(apResult, 'Toggle AccessPoints'); } + + return ssidPaths.length; } catch (e) { if (e is ServiceError) rethrow; throw mapUspErrorToServiceError(e); } } + /// Parses a USP Set result and throws the appropriate [ServiceError] when it + /// is not a complete success. [label] prefixes the error summary. + void _throwIfNotSuccess(Map result, String label) { + final parsed = UspResultParser.parseSetResult(result); + switch (parsed) { + case UspSuccess(): + return; + case UspPartialSuccess(failures: final f): + throw UspPartialFailureError( + summary: '$label partial failure: ${f.first.errorMessage}', + successPaths: [], + failures: f, + ); + case UspFailure(errors: final e): + throw UspCompleteFailureError( + summary: '$label failed: ${e.first.errorMessage}', + failures: e, + ); + } + } + /// Returns the effective security mode to apply to a given band. /// /// 6 GHz (Wi-Fi 6E) mandates WPA3: - /// - Open / Enhanced-Open selected → send "Enhanced-Open" - /// - Any other mode → send "WPA3-Personal" + /// - Open / OWE (Enhanced Open) selected → send "OWE" + /// - Any other mode → send "WPA3-Personal" /// + /// 'OWE' is the TR-181 token firmware accepts for Enhanced Open. /// All other bands: return [selectedMode] unchanged. String _securityModeFor6GHz({ required String band, required String selectedMode, }) { if (!band.contains('6')) return selectedMode; - const openModes = {'None', 'Enhanced-Open', ''}; - return openModes.contains(selectedMode) ? 'Enhanced-Open' : 'WPA3-Personal'; + const openModes = {'None', 'OWE', ''}; + return openModes.contains(selectedMode) ? 'OWE' : 'WPA3-Personal'; } } @@ -616,38 +660,6 @@ List _parseModesSupported(String raw) { .toList(); } -/// Parses a TR-181 PossibleChannels string into a sorted list of channel numbers. -/// Handles both comma-separated values and range notation. -/// e.g. "1-13,36,40,44,48" → [1,2,3,4,5,6,7,8,9,10,11,12,13,36,40,44,48] -List _parsePossibleChannels(String raw) { - if (raw.isEmpty) return []; - final result = []; - for (final part in raw.split(',')) { - final trimmed = part.trim(); - if (trimmed.contains('-')) { - final bounds = trimmed.split('-'); - final start = int.tryParse(bounds[0].trim()); - final end = int.tryParse(bounds[1].trim()); - if (start != null && end != null) { - for (var i = start; i <= end; i++) { - result.add(i); - } - } - } else { - final ch = int.tryParse(trimmed); - if (ch != null) result.add(ch); - } - } - result.sort(); - return result; -} - -/// Ensures a TR-181 path ends with a dot. -String _ensureTrailingDot(String path) { - if (path.isEmpty) return path; - return path.endsWith('.') ? path : '$path.'; -} - /// Parses a TR-181 SupportedOperatingChannelBandwidths string. /// e.g. "Auto,20MHz,40MHz,80MHz" → ['Auto', '20MHz', '40MHz', '80MHz'] List _parseSupportedBandwidths(String raw) { diff --git a/lib/page/wifi_settings/views/components/wifi_network_card.dart b/lib/page/wifi_settings/views/components/wifi_network_card.dart index de2b9ff82..c9782248c 100644 --- a/lib/page/wifi_settings/views/components/wifi_network_card.dart +++ b/lib/page/wifi_settings/views/components/wifi_network_card.dart @@ -71,6 +71,9 @@ class WifiNetworkCard extends ConsumerWidget { onTap: () => _editSsid(context, ref, n), ), // ── WiFi password & Security mode ──────────────────────────── + // Shown for both main and guest whenever the AP reports supported + // security modes. Guest security mode is editable too — consistent + // with the Quick Setup card. if (n.supportedSecurityModes.isNotEmpty) ...[ SettingBlock( title: loc(context).password, @@ -78,13 +81,12 @@ class WifiNetworkCard extends ConsumerWidget { trailing: const AppIcon.font(AppFontIcons.edit), onTap: () => _editPassword(context, ref, n), ), - if (!n.isGuest) - SettingBlock( - title: loc(context).securityMode, - value: n.securityMode, - trailing: const AppIcon.font(AppFontIcons.edit), - onTap: () => _editSecurityMode(context, ref, n), - ), + SettingBlock( + title: loc(context).securityMode, + value: n.securityMode, + trailing: const AppIcon.font(AppFontIcons.edit), + onTap: () => _editSecurityMode(context, ref, n), + ), ], // ── WiFi Mode ────────────────────────────────────────────────── if (!n.isGuest && n.supportedStandards.isNotEmpty) @@ -463,6 +465,11 @@ class WifiNetworkCard extends ConsumerWidget { String selected = channelItems.any((e) => e.value == currentLabel) ? currentLabel : autoLabel; + // Baseline for the no-op check: the selection the dialog opens on. A stored + // channel that isn't selectable — e.g. a DFS channel the firmware left set + // while DFS is off — opens as Auto, and confirming without moving must NOT + // be treated as a change. + final initialSelected = selected; final result = await showSimpleAppDialog( context, @@ -483,7 +490,7 @@ class WifiNetworkCard extends ConsumerWidget { label: loc(context).ok, onTap: () => context.pop(selected)), ], ); - if (result != null && result != currentLabel && context.mounted) { + if (result != null && result != initialSelected && context.mounted) { if (result == autoLabel) { ref.read(uspWifiSettingsProvider.notifier).updateNetworkField( ssidInstancePath, diff --git a/lib/page/wifi_settings/views/tabs/wifi_advanced_tab.dart b/lib/page/wifi_settings/views/tabs/wifi_advanced_tab.dart index 9f63c2fdc..560b223b1 100644 --- a/lib/page/wifi_settings/views/tabs/wifi_advanced_tab.dart +++ b/lib/page/wifi_settings/views/tabs/wifi_advanced_tab.dart @@ -34,6 +34,7 @@ class UspWifiAdvancedTab extends ConsumerWidget { if (status.error != null) { return ServiceErrorView( error: status.error, + title: loc(context).failedToLoadSettings, onRetry: () => ref.read(uspWifiAdvancedProvider.notifier).fetch(forceRemote: true), ); diff --git a/lib/providers/auth/auth_provider.dart b/lib/providers/auth/auth_provider.dart index 296c5643b..7dbcbe310 100644 --- a/lib/providers/auth/auth_provider.dart +++ b/lib/providers/auth/auth_provider.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:privacy_gui/constants/error_code.dart'; import 'package:privacy_gui/constants/pref_key.dart'; import 'package:privacy_gui/core/connection/services/router_fingerprint_service.dart'; @@ -10,6 +9,7 @@ import 'package:privacy_gui/core/session/providers/session_provider.dart'; import 'package:privacy_gui/core/utils/logger.dart'; import 'package:privacy_gui/core/usp/providers/sse_providers.dart'; import 'package:privacy_gui/core/usp/providers/usp_auth_coordinator.dart'; +import 'package:privacy_gui/core/usp/providers/usp_client_provider.dart'; import 'package:privacy_gui/providers/auth/auth_service.dart'; import 'package:privacy_gui/providers/auth/auth_state.dart'; import 'package:privacy_gui/providers/auth/auth_types.dart'; @@ -44,32 +44,26 @@ class AuthNotifier extends AsyncNotifier { // synchronous state change would trigger provider notifications that // cause a !_dirty assertion in ProviderScope. state = await AsyncValue.guard(() async { - // Determine login type from stored credentials - final loginTypeResult = await _authService.getStoredLoginType(); - final loginType = loginTypeResult.when( - success: (type) => type, - failure: (_) => LoginType.none, - ); - - // Get stored local password - final passwordResult = await _authService.getStoredLocalPassword(); - final localPassword = passwordResult.when( - success: (p) => p, - failure: (_) => null, - ); - - logger.d( - '[Auth]init: hasPassword=${localPassword != null}, loginType=$loginType'); - - // Restore USP session on page reload / app restart (local login only) - if (loginType == LoginType.local) { - await ref.read(uspAuthCoordinatorProvider).restoreSession(); - } + // Try to restore USP session from stored token (sessionStorage). + // Note: restoreSession() may call onForceLogout on failure, but at this + // point sseManagerProvider hasn't been watched yet, so onForceLogout is + // null and the call is a no-op. This is safe but relies on init order — + // if SSE initialization ever moves earlier, consider passing + // isRecovering: true here to explicitly suppress force logout. + final coordinator = ref.read(uspAuthCoordinatorProvider); + await coordinator.restoreSession(); + + // Determine login type based on whether session was restored + final isAuthenticated = + ref.read(uspClientProvider)?.isAuthenticated ?? false; + final loginType = isAuthenticated ? LoginType.local : LoginType.none; + + logger.d('[Auth]init: isAuthenticated=$isAuthenticated, ' + 'loginType=$loginType'); return AuthState( localPasswordHint: state.value?.localPasswordHint, loginType: loginType, - localPassword: localPassword, ); }); // AsyncValue.guard never throws — it converts errors to AsyncError. @@ -89,6 +83,9 @@ class AuthNotifier extends AsyncNotifier { } /// Performs local login via USP. + /// + /// Password is used for authentication only — never stored. + /// Session token is persisted by [UspAuthCoordinator] for page reload recovery. Future localLogin( String password, { bool guardError = true, @@ -98,9 +95,6 @@ class AuthNotifier extends AsyncNotifier { try { final uspCoordinator = ref.read(uspAuthCoordinatorProvider); await uspCoordinator.tryUspLogin(password); - - await const FlutterSecureStorage() - .write(key: pLocalPassword, value: password); logger.d('[Auth]: localLogin: USP login succeeded'); // Fetch device info and store fingerprint while auth stays in loading — @@ -110,7 +104,6 @@ class AuthNotifier extends AsyncNotifier { .fetchDeviceInfoAndInitializeServices(); state = AsyncValue.data(previousState.copyWith( - localPassword: password, loginType: LoginType.local, )); } catch (e, st) { @@ -164,24 +157,16 @@ class AuthNotifier extends AsyncNotifier { ); } - /// Persists local credentials without attempting USP login. + /// Sets auth state to local login without attempting USP login. /// - /// Use this when the USP session is already established (e.g., PnP flow) - /// and you only need to save credentials for future session restore. - Future persistLocalCredentials(String password) async { + /// Use this when the USP session is already established (e.g., PnP flow). + /// Token persistence is handled by [UspAuthCoordinator] automatically. + void markAsLocalLogin() { final previousState = state.value ?? AuthState.empty(); - state = const AsyncValue.loading(); - state = await AsyncValue.guard(() async { - // Store password for session restore - await const FlutterSecureStorage() - .write(key: pLocalPassword, value: password); - logger.d('[Auth]: persistLocalCredentials: credentials saved'); - return previousState.copyWith( - localPassword: password, - loginType: LoginType.local, - ); - }); - logger.d('[Auth]: persistLocalCredentials: done, state=$state'); + state = AsyncValue.data(previousState.copyWith( + loginType: LoginType.local, + )); + logger.d('[Auth]: markAsLocalLogin: done'); } /// Sets the login type directly without performing login. diff --git a/lib/providers/auth/auth_service.dart b/lib/providers/auth/auth_service.dart index 30b8c12fa..2abdc5132 100644 --- a/lib/providers/auth/auth_service.dart +++ b/lib/providers/auth/auth_service.dart @@ -1,79 +1,27 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:privacy_gui/constants/pref_key.dart'; -import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/core/utils/logger.dart'; -import 'package:privacy_gui/providers/auth/auth_result.dart'; -import 'package:privacy_gui/providers/auth/auth_types.dart'; /// Provider for AuthService singleton instance. final authServiceProvider = Provider((ref) { - return AuthService(const FlutterSecureStorage()); + return AuthService(); }); /// Stateless service encapsulating local authentication business logic. /// -/// This service handles: -/// - Credential persistence using FlutterSecureStorage -/// - Logout operations that clear all authentication data +/// Note: Password is no longer stored — only session tokens are persisted +/// via [UspTokenStorage] in sessionStorage for page reload recovery. class AuthService { - final FlutterSecureStorage _secureStorage; - - AuthService(this._secureStorage); + AuthService(); // ============================================================================ - // Credential Persistence + // Logout // ============================================================================ - Future> getStoredLocalPassword() async { - try { - final localPassword = await _secureStorage.read(key: pLocalPassword); - logger.d( - '[AuthService]: Stored local password exists: ${localPassword != null}'); - return AuthSuccess(localPassword); - } catch (e) { - logger.e('[AuthService]: Failed to retrieve local password: $e'); - return AuthFailure(StorageError(originalError: e)); - } - } - - Future> getStoredLoginType() async { - try { - final localPassword = await _secureStorage.read(key: pLocalPassword); - - final loginType = - localPassword != null ? LoginType.local : LoginType.none; - - logger.d('[AuthService]: Stored login type: $loginType'); - return AuthSuccess(loginType); - } catch (e) { - logger.e('[AuthService]: Failed to get stored login type: $e'); - return AuthFailure(StorageError(originalError: e)); - } - } - - Future> saveLocalPassword(String password) async { - try { - await _secureStorage.write(key: pLocalPassword, value: password); - logger.d('[AuthService]: Local password saved'); - return const AuthSuccess(null); - } catch (e) { - logger.e('[AuthService]: Failed to save local password: $e'); - return AuthFailure(StorageError(originalError: e)); - } - } - - Future> clearAllCredentials() async { - try { - logger.d('[AuthService]: Clearing all credentials'); - - await _secureStorage.delete(key: pLocalPassword); - - logger.d('[AuthService]: All credentials cleared successfully'); - return const AuthSuccess(null); - } catch (e) { - logger.e('[AuthService]: Failed to clear credentials: $e'); - return AuthFailure(StorageError(originalError: e)); - } + /// Clears all authentication-related data. + /// + /// Note: Token storage is cleared by [UspAuthCoordinator.syncAfterLogout]. + Future clearAllCredentials() async { + logger.d('[AuthService]: clearAllCredentials called (no-op, ' + 'token storage cleared by UspAuthCoordinator)'); } } diff --git a/lib/providers/auth/auth_state.dart b/lib/providers/auth/auth_state.dart index 7d9a4062d..52738dac6 100644 --- a/lib/providers/auth/auth_state.dart +++ b/lib/providers/auth/auth_state.dart @@ -5,21 +5,25 @@ import 'package:privacy_gui/providers/auth/auth_types.dart'; /// Represents the authentication state of the application. /// /// This immutable class holds local router authentication data. +/// Password is never stored — only session tokens are persisted +/// via sessionStorage for page reload recovery. /// /// Use [AuthState.empty] to create an initial unauthenticated state. /// Use [copyWith] to create modified copies with updated values. class AuthState extends Equatable { - /// Local router admin password. - final String? localPassword; - /// Password hint for the local router admin password. final String? localPasswordHint; - /// Current login type indicating authentication method. + /// The single source of truth for login state: `none` / `local` / `remote`. + /// + /// The [isLoggedIn] and [isRemoteAssistance] getters are just named shortcuts + /// derived from this value for the two most common yes/no questions — they are + /// not a separate mechanism. Read [loginType] directly only when the full + /// three-state value is needed (e.g. detecting a transition between kinds, or + /// branching specifically on `local`); prefer the getters otherwise. final LoginType loginType; const AuthState({ - this.localPassword, this.localPasswordHint, required this.loginType, }); @@ -29,13 +33,30 @@ class AuthState extends Equatable { return const AuthState(loginType: LoginType.none); } + /// Whether the user is logged in (local OR Remote Assistance). + /// + /// Shortcut for `loginType != LoginType.none`. This is login *intent* — + /// distinct from the transport-layer [UspClient.isAuthenticated] (a WASM flag + /// that stays false in RA mode). Canonical replacement for scattered inline + /// `loginType != LoginType.none` checks; use [isRemoteAssistance] when the + /// login *kind* matters. + bool get isLoggedIn => loginType != LoginType.none; + + /// Whether this is a Remote Assistance session. + /// + /// Shortcut for `loginType == LoginType.remote`. In RA mode the WASM client + /// is pre-authorized via authToken, so [UspClient.isAuthenticated] stays false + /// by design — RA must be detected from login intent (this getter), not from + /// the client's login state. Canonical replacement for scattered inline + /// `loginType == LoginType.remote` checks. + bool get isRemoteAssistance => loginType == LoginType.remote; + /// Creates an [AuthState] from a JSON map. factory AuthState.fromJson(Map json) { final loginType = LoginType.values.firstWhereOrNull((e) => e.name == json['loginType']) ?? LoginType.none; return AuthState( - localPassword: json['localPassword'], localPasswordHint: json['localPasswordHint'], loginType: loginType, ); @@ -43,12 +64,10 @@ class AuthState extends Equatable { /// Creates a copy of this [AuthState] with the given fields replaced. AuthState copyWith({ - String? localPassword, String? localPasswordHint, LoginType? loginType, }) { return AuthState( - localPassword: localPassword ?? this.localPassword, localPasswordHint: localPasswordHint ?? this.localPasswordHint, loginType: loginType ?? this.loginType, ); @@ -56,7 +75,6 @@ class AuthState extends Equatable { @override List get props => [ - localPassword, localPasswordHint, loginType, ]; diff --git a/lib/route/linksys_route.dart b/lib/route/linksys_route.dart deleted file mode 100644 index 1de202d4c..000000000 --- a/lib/route/linksys_route.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/widgets.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; -import 'package:privacy_gui/components/shortcuts/dialogs.dart'; -import 'package:privacy_gui/framework/feature_state.dart'; -import 'package:privacy_gui/route/route_model.dart'; - -class LinksysRoute extends GoRoute { - final LinksysRouteConfig? config; - - LinksysRoute({ - required super.path, - super.name, - super.builder, - super.routes, - this.config, - // New optional parameters for dirty checking - ProviderListenable? provider, - bool enableDirtyCheck = false, - FutureOr Function(BuildContext, GoRouterState)? onExit, - }) : super( - onExit: (context, state) async { - // First, run any custom onExit logic provided by the developer. - if (onExit != null) { - if (!await onExit(context, state)) { - return false; // Custom logic blocked navigation. - } - if (!context.mounted) return true; - } - - // If dirty checking is enabled and a provider is given... - if (enableDirtyCheck && provider != null) { - final container = ProviderScope.containerOf(context); - final currentState = container.read(provider); - - if (currentState.isDirty) { - final bool? confirmed = await showUnsavedAlert(context); - if (!context.mounted) return true; - if (confirmed != true) { - return false; // User cancelled, block navigation. - } - } - } - - // Allow navigation to proceed. - return true; - }, - ); -} diff --git a/lib/route/route_usp_dashboard.dart b/lib/route/route_usp_dashboard.dart index 6a0993268..3faf4da6b 100644 --- a/lib/route/route_usp_dashboard.dart +++ b/lib/route/route_usp_dashboard.dart @@ -19,11 +19,44 @@ final uspDashboardRoute = ShellRoute( }); return const UspDashboardView(); }, + onExit: (context, state) async { + // Cancel edit mode when navigating away from dashboard (e.g., tab + // switch), reverting to the pre-edit snapshot. + // + // Intentional silent-discard policy: unlike the enableDirtyCheck routes + // below, the dashboard does NOT prompt with showUnsavedAlert. Layout + // edits are persisted on every drag/resize, so "cancel" means restoring + // the snapshot captured on edit-mode entry — there is no unsaved buffer + // to warn about, and a confirmation dialog on every tab switch would be + // noise. See #1037. + final container = ProviderScope.containerOf(context); + final editState = container.read(dashboardEditModeProvider); + if (editState.isEditing) { + try { + await container + .read(dashboardEditModeProvider.notifier) + .cancelEditMode(); + } catch (e, s) { + // Never block navigation on a revert failure; cancelEditMode resets + // its own state in a finally block, so edit mode won't be stranded. + logger.e('[Route]: dashboard cancelEditMode failed on exit', + error: e, stackTrace: s); + } + } + return true; + }, ), LinksysRoute( name: RouteNamed.uspMenu, path: RoutePath.uspMenu, builder: (context, state) => const UspMenuView(), + routes: [ + LinksysRoute( + name: RouteNamed.uspUnifiedDiagnostics, + path: RoutePath.uspUnifiedDiagnostics, + builder: (context, state) => const UnifiedDiagnosticsView(), + ), + ], ), LinksysRoute( name: RouteNamed.uspSupport, @@ -102,7 +135,11 @@ final uspDashboardRoute = ShellRoute( LinksysRoute( name: RouteNamed.uspStatistics, path: RoutePath.uspStatistics, - builder: (context, state) => const UspStatisticsView(), + builder: (context, state) { + final tabParam = state.uri.queryParameters['tab']; + final initialTab = int.tryParse(tabParam ?? '') ?? 0; + return UspStatisticsView(initialTab: initialTab); + }, ), LinksysRoute( name: RouteNamed.uspAdvancedSettings, @@ -179,11 +216,6 @@ final uspDashboardRoute = ShellRoute( path: RoutePath.uspApps, builder: (context, state) => const UspAppsView(), ), - LinksysRoute( - name: RouteNamed.uspUnifiedDiagnostics, - path: RoutePath.uspUnifiedDiagnostics, - builder: (context, state) => const UnifiedDiagnosticsView(), - ), LinksysRoute( name: RouteNamed.uspSpeedTest, path: RoutePath.uspSpeedTest, diff --git a/lib/route/router_provider.dart b/lib/route/router_provider.dart index 667407dd4..a7c43ec05 100644 --- a/lib/route/router_provider.dart +++ b/lib/route/router_provider.dart @@ -24,6 +24,7 @@ import 'navigation_extra.dart'; // USP dashboard imports import 'package:privacy_gui/page/_shared/providers/usp_bars_visible_provider.dart'; +import 'package:privacy_gui/page/dashboard/providers/dashboard_edit_mode_provider.dart'; import 'package:privacy_gui/page/dashboard/views/usp_dashboard_view.dart'; import 'package:privacy_gui/page/menu/views/usp_menu_view.dart'; import 'package:privacy_gui/page/support/views/usp_support_view.dart'; @@ -139,9 +140,9 @@ final routerProvider = Provider((ref) { // In Remote build mode, redirect to confirm page with restored session params. if (GlobalConfig.remote.isActive) { // If already connected (USP layer active), allow access - final loginType = - ref.read(authProvider.select((value) => value.value?.loginType)); - if (loginType == LoginType.remote) { + final isRemoteAssistance = ref.read(authProvider + .select((value) => value.value?.isRemoteAssistance ?? false)); + if (isRemoteAssistance) { return state.uri.toString(); } @@ -158,9 +159,9 @@ final routerProvider = Provider((ref) { logger.i('[Route]: Remote mode no session, redirecting to RA page'); return RoutePath.remoteAssistanceConfirm; } - final loginType = - ref.watch(authProvider.select((value) => value.value?.loginType)); - if (loginType == null || loginType == LoginType.none) { + final isLoggedIn = ref.watch( + authProvider.select((value) => value.value?.isLoggedIn ?? false)); + if (!isLoggedIn) { return router._home(); } return state.uri.toString(); @@ -220,7 +221,7 @@ class RouterNotifier extends ChangeNotifier { final loginType = _ref.watch(authProvider.select((data) => data.value?.loginType)); - // if have no login type and navigate into dashboard, then back to home + // if not logged in and navigate into dashboard, then back to home if ((loginType == null || loginType == LoginType.none) && (state.matchedLocation.startsWith('/dashboard') || state.matchedLocation.startsWith('/usp'))) { diff --git a/lib/validator_rules/rules.dart b/lib/validator_rules/rules.dart index a38a801e7..ba1afe537 100644 --- a/lib/validator_rules/rules.dart +++ b/lib/validator_rules/rules.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:privacy_gui/core/utils/ipv6_ranges.dart'; import 'package:privacy_gui/util/network_utils.dart'; abstract class ValidationRule { @@ -259,21 +260,21 @@ class IPv6WithReservedRule extends ValidationRule { } // 6b. Check for 3ffe::/16 (6bone - deprecated IPv6 testing network). - if (rawAddress[0] == 0x3F && rawAddress[1] == 0xFE) { + if (is6boneBytes(rawAddress[0], rawAddress[1])) { return false; } // 6c. Check for other reserved ranges within 2000::/3 (e.g., 5F00::/12, 6000::/3 to 7FFF::/3). - if (rawAddress[0] >= 0x5F && rawAddress[0] <= 0x7F) { + if (isReservedGlobalByte(rawAddress[0])) { return false; } // --- Rule: Must be a unicast address usable for port service --- // Allowed: Global Unicast (2000::/3), Link-local (fe80::/10), ULA (fc00::/7) final firstByte = rawAddress[0]; - final isGlobalUnicast = firstByte >= 0x20 && firstByte <= 0x3F; - final isLinkLocal = firstByte == 0xFE && (rawAddress[1] & 0xC0) == 0x80; - final isULA = firstByte == 0xFC || firstByte == 0xFD; + final isGlobalUnicast = isGlobalUnicastByte(firstByte); + final isLinkLocal = isLinkLocalBytes(firstByte, rawAddress[1]); + final isULA = isUniqueLocalByte(firstByte); if (!isGlobalUnicast && !isLinkLocal && !isULA) { return false; diff --git a/pubspec.yaml b/pubspec.yaml index a2cc4ab38..49343abef 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,7 +15,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 2.5.0+100000 +version: 2.6.0+100000 environment: sdk: ">=3.3.0 <4.0.0" @@ -59,11 +59,11 @@ dependencies: ui_kit_library: git: url: https://github.com/linksys/privacyGUI-UI-kit.git - ref: v2.25.1 + ref: v2.28.1 generative_ui: git: url: https://github.com/linksys/privacyGUI-UI-kit.git - ref: v2.25.1 + ref: v2.28.1 path: generative_ui flutter_blue_plus: ^1.4.0 crypto: ^3.0.2 @@ -129,7 +129,47 @@ flutter: - assets/resources/ - assets/a2ui/widgets/ - assets/theme/ - + - assets/fonts/fallback/ + + # Non-Latin fallback fonts (CJK subsets built from the interface charset — see + # tools/font_subset/ — plus Thai/Arabic/Latin-ext and Roboto). Declared under + # fonts: so the engine registers them before the first frame; this is what + # stops the CanvasKit fallback manager from probing the CDN (verified: as + # assets-only they were probed; declared here, zero CDN). Family names are + # package-prefixed to match ui_kit's AppText fallback injection and app.dart's + # textTheme fallback. Offline-first: all locales must render without network. + fonts: + - family: packages/ui_kit_library/NotoSansSC + fonts: + - asset: assets/fonts/fallback/NotoSansCJKsc.subset.woff2 + - family: packages/ui_kit_library/NotoSansTC + fonts: + - asset: assets/fonts/fallback/NotoSansCJKtc.subset.woff2 + - family: packages/ui_kit_library/NotoSansHK + fonts: + - asset: assets/fonts/fallback/NotoSansCJKhk.subset.woff2 + - family: packages/ui_kit_library/NotoSansJP + fonts: + - asset: assets/fonts/fallback/NotoSansCJKjp.subset.woff2 + - family: packages/ui_kit_library/NotoSansKR + fonts: + - asset: assets/fonts/fallback/NotoSansCJKkr.subset.woff2 + - family: packages/ui_kit_library/NotoSansThai + fonts: + - asset: assets/fonts/fallback/NotoSansThai.woff2 + - family: packages/ui_kit_library/NotoSansArabic + fonts: + - asset: assets/fonts/fallback/NotoSansArabic.woff2 + - family: packages/ui_kit_library/NotoSansLatinExt + fonts: + - asset: assets/fonts/fallback/NotoSans-Latin.woff2 + # Roboto is the engine's built-in default global fallback + # (globalFontFallbacks = ['Roboto']). Declared with its BARE family name so + # the engine finds it locally instead of probing the CDN on startup. + - family: Roboto + fonts: + - asset: assets/fonts/fallback/Roboto.woff2 + # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/assets-and-images/#resolution-aware. diff --git a/test/ai/providers/usp_command_provider_test.dart b/test/ai/providers/usp_command_provider_test.dart index 625f03e6d..cec2aa6b2 100644 --- a/test/ai/providers/usp_command_provider_test.dart +++ b/test/ai/providers/usp_command_provider_test.dart @@ -2,9 +2,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:privacy_gui/ai/abstraction/_abstraction.dart'; import 'package:privacy_gui/ai/providers/usp_command_provider.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_network.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; import 'package:privacy_gui/page/_shared/models/system_info_ui_model.dart'; import 'package:privacy_gui/page/_shared/models/wan_status_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/wifi_connection_info.dart'; import 'package:privacy_gui/page/_shared/models/wifi_radio_ui_model.dart'; import 'package:privacy_gui/page/admin/providers/system_info_data_provider.dart'; import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; @@ -425,26 +428,30 @@ final _testSystemInfoData = SystemInfoData( ); final _testDevicesData = DevicesData( - deviceModels: const [ - DeviceUIModel( - mac: 'AA:BB:CC:DD:EE:01', - ip: '192.168.1.100', - hostName: 'iPhone-15-Pro', - isActive: true, - isWifi: true, - signalStrength: -42, - band: '5GHz', - ), - DeviceUIModel( - mac: 'AA:BB:CC:DD:EE:02', - ip: '192.168.1.101', - hostName: 'MacBook-Air', - isActive: true, - isWifi: true, - signalStrength: -68, - band: '5GHz', + meshNetwork: MeshNetwork( + master: MasterNode( + deviceId: 'GATEWAY', + model: 'MR7500', + connectedClients: [ + ClientDevice( + mac: 'AA:BB:CC:DD:EE:01', + ip: '192.168.1.100', + hostName: 'iPhone-15-Pro', + isActive: true, + connectionType: ConnectionType.wifi, + wifi: WifiConnectionInfo(signalStrength: -42, band: '5GHz'), + ), + ClientDevice( + mac: 'AA:BB:CC:DD:EE:02', + ip: '192.168.1.101', + hostName: 'MacBook-Air', + isActive: true, + connectionType: ConnectionType.wifi, + wifi: WifiConnectionInfo(signalStrength: -68, band: '5GHz'), + ), + ], ), - ], + ), ); final _testWifiData = WifiData( diff --git a/test/components/views/service_error_view_test.dart b/test/components/views/service_error_view_test.dart index eb3a4f896..ff3678b1e 100644 --- a/test/components/views/service_error_view_test.dart +++ b/test/components/views/service_error_view_test.dart @@ -28,22 +28,24 @@ Widget _wrap(Widget child) => MaterialApp( void main() { group('ServiceErrorView', () { - testWidgets('shows title, localized error detail, and retry when error set', + testWidgets('uses the provided title and shows localized detail + retry', (tester) async { + final en = lookupAppLocalizations(const Locale('en')); await tester.pumpWidget(_wrap(ServiceErrorView( error: const NetworkError(), + title: en.failedToLoadSettings, onRetry: () {}, ))); await tester.pumpAndSettle(); - final en = lookupAppLocalizations(const Locale('en')); expect(find.text(en.failedToLoadSettings), findsOneWidget); // NetworkError → errorNetwork (the localized detail line). expect(find.text(en.errorNetwork), findsOneWidget); expect(find.text(en.retry), findsOneWidget); }); - testWidgets('hides the detail line when error is null', (tester) async { + testWidgets('falls back to a neutral title when none is provided', + (tester) async { await tester.pumpWidget(_wrap(ServiceErrorView( error: null, onRetry: () {}, @@ -51,11 +53,26 @@ void main() { await tester.pumpAndSettle(); final en = lookupAppLocalizations(const Locale('en')); + // Default title is the neutral errorUnexpected; retry still renders. + expect(find.text(en.errorUnexpected), findsOneWidget); + expect(find.text(en.retry), findsOneWidget); + // No error object → no separate detail line. + expect(find.text(en.errorNetwork), findsNothing); + }); + + testWidgets('hides the detail line when error is null', (tester) async { + final en = lookupAppLocalizations(const Locale('en')); + await tester.pumpWidget(_wrap(ServiceErrorView( + error: null, + title: en.failedToLoadSettings, + onRetry: () {}, + ))); + await tester.pumpAndSettle(); + // Title + retry still render; no error-detail strings present. expect(find.text(en.failedToLoadSettings), findsOneWidget); expect(find.text(en.retry), findsOneWidget); expect(find.text(en.errorNetwork), findsNothing); - expect(find.text(en.errorUnexpected), findsNothing); }); testWidgets('invokes onRetry when the retry button is tapped', @@ -73,5 +90,35 @@ void main() { expect(tapped, 1); }); + + testWidgets('does not show a secondary action by default', (tester) async { + await tester.pumpWidget(_wrap(ServiceErrorView( + error: const NetworkError(), + onRetry: () {}, + ))); + await tester.pumpAndSettle(); + + final en = lookupAppLocalizations(const Locale('en')); + expect(find.text(en.logout), findsNothing); + }); + + testWidgets('shows and invokes the secondary action when provided', + (tester) async { + var secondary = 0; + await tester.pumpWidget(_wrap(ServiceErrorView( + error: const NetworkError(), + onRetry: () {}, + secondaryLabel: lookupAppLocalizations(const Locale('en')).logout, + onSecondary: () => secondary++, + ))); + await tester.pumpAndSettle(); + + final en = lookupAppLocalizations(const Locale('en')); + expect(find.text(en.logout), findsOneWidget); + await tester.tap(find.text(en.logout)); + await tester.pumpAndSettle(); + + expect(secondary, 1); + }); }); } diff --git a/test/core/cloud/providers/remote_assistance/device_credentials_provider_test.dart b/test/core/cloud/providers/remote_assistance/device_credentials_provider_test.dart new file mode 100644 index 000000000..dd0b6076a --- /dev/null +++ b/test/core/cloud/providers/remote_assistance/device_credentials_provider_test.dart @@ -0,0 +1,159 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:privacy_gui/core/cloud/providers/remote_assistance/device_credentials_provider.dart'; +import 'package:privacy_gui/core/models/device_info.dart'; +import 'package:privacy_gui/core/session/providers/session_provider.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_network.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; +import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; + +// ============================================================================= +// deviceCredentialsProvider — verifies the Hosts DeviceID (UUID) is used for +// Remote Assistance / Guardian API calls, NOT the master's MAC address. +// ============================================================================= + +void main() { + const deviceInfo = NodeDeviceInfo( + modelNumber: 'M60TB', + firmwareVersion: '1.0.16', + description: '', + firmwareDate: '', + manufacturer: 'Linksys', + serialNumber: 'SN-12345', + hardwareVersion: '1', + ); + + const masterMac = 'AA:BB:CC:DD:EE:FF'; + const hostsUuid = 'uuid-hosts-9876'; + + DevicesData devicesDataWith({ + String? hostsDeviceId, + String deviceId = masterMac, + }) { + return DevicesData( + meshNetwork: MeshNetwork( + master: MasterNode( + deviceId: deviceId, + model: 'M60TB', + hostsDeviceId: hostsDeviceId, + ), + ), + ); + } + + ProviderContainer createContainer({ + required DevicesData? devices, + NodeDeviceInfo? session = deviceInfo, + }) { + return ProviderContainer( + overrides: [ + sessionProvider.overrideWith(() => _FakeSessionNotifier(session)), + devicesDataProvider.overrideWith(() => _FakeDevicesNotifier(devices)), + ], + ); + } + + /// Ensures [devicesDataProvider] has resolved (its AsyncNotifier is async, + /// so a synchronous read would otherwise see AsyncLoading → valueOrNull null). + Future settleDevices(ProviderContainer container) async { + try { + await container.read(devicesDataProvider.future); + } catch (_) { + // Unavailable case: build throws → AsyncError → valueOrNull null. + } + } + + group('deviceCredentialsProvider', () { + test('uses Hosts DeviceID (UUID) as deviceUUID, not the MAC', () async { + final container = createContainer( + devices: devicesDataWith(hostsDeviceId: hostsUuid), + ); + addTearDown(container.dispose); + await settleDevices(container); + + final creds = container.read(deviceCredentialsProvider); + + expect(creds, isNotNull); + expect(creds!.deviceUUID, hostsUuid); + // MAC is still carried separately. + expect(creds.macAddress, masterMac); + expect(creds.serialNumber, 'SN-12345'); + // deviceUUID must NOT fall back to the MAC address (the regression). + expect(creds.deviceUUID, isNot(masterMac)); + }); + + test('returns null when master has no Hosts DeviceID', () async { + final container = createContainer( + devices: devicesDataWith(hostsDeviceId: null), + ); + addTearDown(container.dispose); + await settleDevices(container); + + expect(container.read(deviceCredentialsProvider), isNull); + }); + + test('returns null when Hosts DeviceID is empty', () async { + final container = createContainer( + devices: devicesDataWith(hostsDeviceId: ''), + ); + addTearDown(container.dispose); + await settleDevices(container); + + expect(container.read(deviceCredentialsProvider), isNull); + }); + + test('returns null when devices data is unavailable', () async { + final container = createContainer(devices: null); + addTearDown(container.dispose); + await settleDevices(container); + + expect(container.read(deviceCredentialsProvider), isNull); + }); + + test('returns null when session deviceInfo is unavailable', () async { + final container = createContainer( + devices: devicesDataWith(hostsDeviceId: hostsUuid), + session: null, + ); + addTearDown(container.dispose); + await settleDevices(container); + + expect(container.read(deviceCredentialsProvider), isNull); + }); + }); +} + +// ============================================================================= +// Fakes +// ============================================================================= + +class _FakeSessionNotifier extends Notifier + implements SessionNotifier { + final NodeDeviceInfo? _deviceInfo; + _FakeSessionNotifier(this._deviceInfo); + + @override + SessionState build() => SessionState(deviceInfo: _deviceInfo); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeDevicesNotifier extends AsyncNotifier + implements DevicesDataNotifier { + final DevicesData? _data; + _FakeDevicesNotifier(this._data); + + @override + Future build() async { + final data = _data; + if (data == null) { + // Simulate "not loaded" — valueOrNull resolves to null via AsyncError. + throw StateError('no devices data'); + } + return data; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/core/connection/integration/recovery_flow_test.dart b/test/core/connection/integration/recovery_flow_test.dart index 3435b2557..e694d54d8 100644 --- a/test/core/connection/integration/recovery_flow_test.dart +++ b/test/core/connection/integration/recovery_flow_test.dart @@ -19,8 +19,9 @@ class MockAuthNotifier extends AsyncNotifier with Mock implements AuthNotifier { @override - Future build() async => - AuthState(loginType: LoginType.local, localPassword: 'test'); + Future build() async => AuthState( + loginType: LoginType.local, + ); } void main() { diff --git a/test/core/connection/providers/app_connection_state_provider_test.dart b/test/core/connection/providers/app_connection_state_provider_test.dart index 293942e1c..77a2a8c1d 100644 --- a/test/core/connection/providers/app_connection_state_provider_test.dart +++ b/test/core/connection/providers/app_connection_state_provider_test.dart @@ -26,8 +26,9 @@ class MockAuthNotifier extends AsyncNotifier with Mock implements AuthNotifier { @override - Future build() async => - AuthState(loginType: LoginType.local, localPassword: 'test'); + Future build() async => AuthState( + loginType: LoginType.local, + ); } void main() { diff --git a/test/core/connection/services/recovery_probe_service_test.dart b/test/core/connection/services/recovery_probe_service_test.dart index 571f0a419..1026e924b 100644 --- a/test/core/connection/services/recovery_probe_service_test.dart +++ b/test/core/connection/services/recovery_probe_service_test.dart @@ -42,7 +42,8 @@ void main() { final result = await service.probe(); expect(result, ProbeResult.unreachable); - verifyNever(() => mockAuth.restoreSession()); + verifyNever(() => + mockAuth.restoreSession(isRecovering: any(named: 'isRecovering'))); }); test('returns unreachable when agent not connected', () async { @@ -55,7 +56,8 @@ void main() { final result = await service.probe(); expect(result, ProbeResult.unreachable); - verifyNever(() => mockAuth.restoreSession()); + verifyNever(() => + mockAuth.restoreSession(isRecovering: any(named: 'isRecovering'))); }); test('returns unreachable when agent_state is not ready', () async { @@ -68,7 +70,8 @@ void main() { final result = await service.probe(); expect(result, ProbeResult.unreachable); - verifyNever(() => mockAuth.restoreSession()); + verifyNever(() => + mockAuth.restoreSession(isRecovering: any(named: 'isRecovering'))); }); test('returns unreachable when health response missing agent fields', @@ -78,13 +81,14 @@ void main() { final result = await service.probe(); expect(result, ProbeResult.unreachable); - verifyNever(() => mockAuth.restoreSession()); + verifyNever(() => + mockAuth.restoreSession(isRecovering: any(named: 'isRecovering'))); }); test('returns unreachable when health OK but login fails', () async { when(() => mockBridge.health()) .thenAnswer((_) async => healthyResponse()); - when(() => mockAuth.restoreSession()) + when(() => mockAuth.restoreSession(isRecovering: true)) .thenThrow(Exception('login failed')); final result = await service.probe(); @@ -97,7 +101,8 @@ void main() { () async { when(() => mockBridge.health()) .thenAnswer((_) async => healthyResponse()); - when(() => mockAuth.restoreSession()).thenAnswer((_) async => {}); + when(() => mockAuth.restoreSession(isRecovering: true)) + .thenAnswer((_) async {}); when(() => mockAuth.getSerialNumber()).thenAnswer((_) async => 'ABC123'); when(() => mockFingerprint.matches('ABC123')) .thenAnswer((_) async => true); @@ -111,7 +116,8 @@ void main() { () async { when(() => mockBridge.health()) .thenAnswer((_) async => healthyResponse()); - when(() => mockAuth.restoreSession()).thenAnswer((_) async => {}); + when(() => mockAuth.restoreSession(isRecovering: true)) + .thenAnswer((_) async {}); when(() => mockAuth.getSerialNumber()).thenAnswer((_) async => 'XYZ789'); when(() => mockFingerprint.matches('XYZ789')) .thenAnswer((_) async => false); @@ -125,7 +131,8 @@ void main() { () async { when(() => mockBridge.health()) .thenAnswer((_) async => healthyResponse()); - when(() => mockAuth.restoreSession()).thenAnswer((_) async => {}); + when(() => mockAuth.restoreSession(isRecovering: true)) + .thenAnswer((_) async {}); when(() => mockAuth.getSerialNumber()).thenThrow(Exception('USP error')); final result = await service.probe(); diff --git a/test/core/usp/providers/usp_auth_coordinator_test.dart b/test/core/usp/providers/usp_auth_coordinator_test.dart index 9a58212c8..d41419582 100644 --- a/test/core/usp/providers/usp_auth_coordinator_test.dart +++ b/test/core/usp/providers/usp_auth_coordinator_test.dart @@ -1,12 +1,12 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:privacy_gui/constants/error_code.dart'; import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/core/usp/providers/usp_auth_coordinator.dart'; +import 'package:privacy_gui/core/usp/providers/usp_token_storage.dart'; import 'package:privacy_gui/core/usp/services/usp_client.dart'; /// Mock that stores callback fields (mocktail Mock swallows setter calls). @@ -21,21 +21,26 @@ class TestUspClient extends Mock implements UspClient { VoidCallback? onForceLogout; } -class MockSecureStorage extends Mock implements FlutterSecureStorage {} +class MockTokenStorage extends Mock implements UspTokenStorage {} void main() { late TestUspClient mockUsp; - late MockSecureStorage mockStorage; + late MockTokenStorage mockStorage; late UspAuthCoordinator coordinator; setUp(() { mockUsp = TestUspClient(); - mockStorage = MockSecureStorage(); + mockStorage = MockTokenStorage(); // Default stubs when(() => mockUsp.isAuthenticated).thenReturn(true); when(() => mockUsp.isReauthInProgress).thenReturn(false); - when(() => mockUsp.refreshToken()).thenAnswer((_) async {}); + when(() => mockUsp.refreshToken(token: any(named: 'token'))) + .thenAnswer((_) async {}); + when(() => mockUsp.sessionToken).thenReturn('test-token'); + when(() => mockStorage.save(any())).thenReturn(null); + when(() => mockStorage.clear()).thenReturn(null); + when(() => mockStorage.load()).thenReturn(null); coordinator = UspAuthCoordinator(mockUsp, mockStorage); }); @@ -47,7 +52,7 @@ void main() { test('refreshes when _lastTokenRefresh is null', () async { await coordinator.ensureAuth(); - verify(() => mockUsp.refreshToken()).called(1); + verify(() => mockUsp.refreshToken(token: null)).called(1); }); test('skips refresh when elapsed < 12 minutes', () async { @@ -56,20 +61,22 @@ void main() { reset(mockUsp); when(() => mockUsp.isAuthenticated).thenReturn(true); when(() => mockUsp.isReauthInProgress).thenReturn(false); - when(() => mockUsp.refreshToken()).thenAnswer((_) async {}); + when(() => mockUsp.refreshToken(token: any(named: 'token'))) + .thenAnswer((_) async {}); + when(() => mockUsp.sessionToken).thenReturn('test-token'); // Immediate second call — well within 12 min await coordinator.ensureAuth(); - verifyNever(() => mockUsp.refreshToken()); + verifyNever(() => mockUsp.refreshToken(token: any(named: 'token'))); }); test('refreshes when elapsed >= 12 minutes', () async { // First call to set _lastTokenRefresh await coordinator.ensureAuth(); - verify(() => mockUsp.refreshToken()).called(1); + verify(() => mockUsp.refreshToken(token: null)).called(1); - // Simulate time passage by calling syncAfterLogout + re-login + // Simulate time passage by calling syncAfterLogout // (which resets _lastTokenRefresh to null) when(() => mockUsp.logout()).thenAnswer((_) async {}); await coordinator.syncAfterLogout(); @@ -79,7 +86,7 @@ void main() { when(() => mockUsp.isReauthInProgress).thenReturn(false); await coordinator.ensureAuth(); - verify(() => mockUsp.refreshToken()).called(1); + verify(() => mockUsp.refreshToken(token: null)).called(1); }); }); @@ -90,11 +97,17 @@ void main() { test('updates _lastTokenRefresh on success', () async { await coordinator.ensureAuth(); - verify(() => mockUsp.refreshToken()).called(1); + verify(() => mockUsp.refreshToken(token: null)).called(1); // Second immediate call should skip (timestamp was set) await coordinator.ensureAuth(); - verifyNever(() => mockUsp.refreshToken()); + verifyNever(() => mockUsp.refreshToken(token: any(named: 'token'))); + }); + + test('persists token on refresh success', () async { + await coordinator.ensureAuth(); + + verify(() => mockStorage.save('test-token')).called(1); }); }); @@ -103,7 +116,7 @@ void main() { // --------------------------------------------------------------------------- group('ensureAuth — 401 error', () { test('calls onForceLogout on HTTP 401', () async { - when(() => mockUsp.refreshToken()) + when(() => mockUsp.refreshToken(token: any(named: 'token'))) .thenThrow(Exception('HTTP 401 Unauthorized')); bool logoutCalled = false; @@ -113,6 +126,16 @@ void main() { expect(logoutCalled, isTrue); }); + + test('clears token storage on 401', () async { + when(() => mockUsp.refreshToken(token: any(named: 'token'))) + .thenThrow(Exception('HTTP 401 Unauthorized')); + coordinator.onForceLogout = () {}; + + await coordinator.ensureAuth(); + + verify(() => mockStorage.clear()).called(1); + }); }); // --------------------------------------------------------------------------- @@ -120,7 +143,7 @@ void main() { // --------------------------------------------------------------------------- group('ensureAuth — network error', () { test('does NOT call onForceLogout on network error', () async { - when(() => mockUsp.refreshToken()) + when(() => mockUsp.refreshToken(token: any(named: 'token'))) .thenThrow(Exception('SocketException: Connection refused')); bool logoutCalled = false; @@ -141,7 +164,7 @@ void main() { await coordinator.ensureAuth(); - verifyNever(() => mockUsp.refreshToken()); + verifyNever(() => mockUsp.refreshToken(token: any(named: 'token'))); }); test('skips when reauth is in progress', () async { @@ -149,12 +172,13 @@ void main() { await coordinator.ensureAuth(); - verifyNever(() => mockUsp.refreshToken()); + verifyNever(() => mockUsp.refreshToken(token: any(named: 'token'))); }); test('concurrent ensureAuth calls do not stack', () async { final completer = Completer(); - when(() => mockUsp.refreshToken()).thenAnswer((_) => completer.future); + when(() => mockUsp.refreshToken(token: any(named: 'token'))) + .thenAnswer((_) => completer.future); // Launch two concurrent calls final f1 = coordinator.ensureAuth(); @@ -165,7 +189,7 @@ void main() { await f2; // refreshToken should only be called once - verify(() => mockUsp.refreshToken()).called(1); + verify(() => mockUsp.refreshToken(token: null)).called(1); }); }); @@ -173,40 +197,31 @@ void main() { // _lastTokenRefresh — login paths // --------------------------------------------------------------------------- group('_lastTokenRefresh updates on login paths', () { - test('syncAfterLocalLogin updates timestamp', () async { + test('syncAfterLocalLogin updates timestamp and persists token', () async { when(() => mockUsp.login(any())).thenAnswer((_) async {}); await coordinator.syncAfterLocalLogin('password'); + // Token should be persisted + verify(() => mockStorage.save('test-token')).called(1); + // Immediate ensureAuth should skip (timestamp just set) await coordinator.ensureAuth(); - verifyNever(() => mockUsp.refreshToken()); + verifyNever(() => mockUsp.refreshToken(token: any(named: 'token'))); }); - test('tryUspLogin updates timestamp on success', () async { + test('tryUspLogin updates timestamp and persists token on success', + () async { when(() => mockUsp.login(any())).thenAnswer((_) async {}); await coordinator.tryUspLogin('password'); - // Immediate ensureAuth should skip - await coordinator.ensureAuth(); - verifyNever(() => mockUsp.refreshToken()); - }); - - test('restoreSession updates timestamp on success', () async { - when(() => mockUsp.isAuthenticated).thenReturn(false); - when(() => mockStorage.read(key: any(named: 'key'))) - .thenAnswer((_) async => 'storedPassword'); - when(() => mockUsp.login(any())).thenAnswer((_) async {}); - - await coordinator.restoreSession(); - - // Re-stub isAuthenticated for ensureAuth check - when(() => mockUsp.isAuthenticated).thenReturn(true); + // Token should be persisted + verify(() => mockStorage.save('test-token')).called(1); // Immediate ensureAuth should skip await coordinator.ensureAuth(); - verifyNever(() => mockUsp.refreshToken()); + verifyNever(() => mockUsp.refreshToken(token: any(named: 'token'))); }); }); @@ -250,10 +265,10 @@ void main() { }); // --------------------------------------------------------------------------- - // syncAfterLogout resets _lastTokenRefresh + // syncAfterLogout resets _lastTokenRefresh and clears token // --------------------------------------------------------------------------- group('syncAfterLogout', () { - test('resets _lastTokenRefresh', () async { + test('resets _lastTokenRefresh and clears token storage', () async { // Set timestamp via login when(() => mockUsp.login(any())).thenAnswer((_) async {}); await coordinator.syncAfterLocalLogin('password'); @@ -262,79 +277,88 @@ void main() { when(() => mockUsp.logout()).thenAnswer((_) async {}); await coordinator.syncAfterLogout(); + // Token storage should be cleared + verify(() => mockStorage.clear()).called(1); + // ensureAuth should now refresh (timestamp is null) await coordinator.ensureAuth(); - verify(() => mockUsp.refreshToken()).called(1); + verify(() => mockUsp.refreshToken(token: null)).called(1); }); }); // --------------------------------------------------------------------------- - // restoreSession — token-first strategy: tries refreshToken() first, falls - // back to password login on 401. Covers reauth Stage 2 and recovery probe. + // restoreSession — token-based strategy // --------------------------------------------------------------------------- - group('restoreSession (onReauthRequired / recovery probe)', () { + group('restoreSession (token-based)', () { test('uses refreshToken when isAuthenticated=true and token is valid', () async { when(() => mockUsp.isAuthenticated).thenReturn(true); - when(() => mockUsp.refreshToken()).thenAnswer((_) async {}); await coordinator.restoreSession(); - verify(() => mockUsp.refreshToken()).called(1); + // Should use in-memory token refresh (no external token) + verify(() => mockUsp.refreshToken(token: null)).called(1); verifyNever(() => mockUsp.login(any())); }); - test('falls back to login when refreshToken returns 401', () async { - // WASM client reports authenticated (stale token in memory) + test('falls back to stored token when in-memory refresh fails with 401', + () async { + // First call with in-memory token fails when(() => mockUsp.isAuthenticated).thenReturn(true); - when(() => mockUsp.refreshToken()) - .thenThrow(Exception('HTTP 401 Unauthorized')); - when(() => mockStorage.read(key: any(named: 'key'))) - .thenAnswer((_) async => 'storedPassword'); - when(() => mockUsp.login(any())).thenAnswer((_) async {}); + var callCount = 0; + when(() => mockUsp.refreshToken(token: any(named: 'token'))) + .thenAnswer((invocation) async { + callCount++; + if (callCount == 1) { + throw Exception('HTTP 401 Unauthorized'); + } + // Second call with stored token succeeds + }); + when(() => mockStorage.load()).thenReturn('stored-token'); - // UspClient wires onReauthRequired to coordinator.restoreSession - final onReauth = mockUsp.onReauthRequired; - expect(onReauth, isNotNull); - await onReauth!(); + await coordinator.restoreSession(); - verify(() => mockUsp.refreshToken()).called(1); - verify(() => mockUsp.login('storedPassword')).called(1); + // Should try in-memory first, then stored token + verify(() => mockUsp.refreshToken(token: null)).called(1); + verify(() => mockUsp.refreshToken(token: 'stored-token')).called(1); }); - test('falls back to login when called directly with stale token', () async { - when(() => mockUsp.isAuthenticated).thenReturn(true); - when(() => mockUsp.refreshToken()) - .thenThrow(Exception('HTTP 401 Unauthorized')); - when(() => mockStorage.read(key: any(named: 'key'))) - .thenAnswer((_) async => 'storedPassword'); - when(() => mockUsp.login(any())).thenAnswer((_) async {}); + test('uses stored token directly when isAuthenticated=false (page reload)', + () async { + when(() => mockUsp.isAuthenticated).thenReturn(false); + when(() => mockStorage.load()).thenReturn('stored-token'); await coordinator.restoreSession(); - verify(() => mockUsp.refreshToken()).called(1); - verify(() => mockUsp.login('storedPassword')).called(1); + verifyNever(() => mockUsp.refreshToken(token: null)); + verify(() => mockUsp.refreshToken(token: 'stored-token')).called(1); }); - test('uses login directly when isAuthenticated=false (page reload)', - () async { + test('returns early when no stored token available', () async { when(() => mockUsp.isAuthenticated).thenReturn(false); - when(() => mockStorage.read(key: any(named: 'key'))) - .thenAnswer((_) async => 'storedPassword'); - when(() => mockUsp.login(any())).thenAnswer((_) async {}); + when(() => mockStorage.load()).thenReturn(null); await coordinator.restoreSession(); - verifyNever(() => mockUsp.refreshToken()); - verify(() => mockUsp.login('storedPassword')).called(1); + verifyNever(() => mockUsp.refreshToken(token: any(named: 'token'))); }); - test('concurrent calls are coalesced — only one login attempt', () async { + test('clears stored token when it is expired/invalid', () async { when(() => mockUsp.isAuthenticated).thenReturn(false); - when(() => mockStorage.read(key: any(named: 'key'))) - .thenAnswer((_) async => 'storedPassword'); - // Slow login to ensure concurrent calls overlap - when(() => mockUsp.login(any())) + when(() => mockStorage.load()).thenReturn('expired-token'); + when(() => mockUsp.refreshToken(token: 'expired-token')) + .thenThrow(Exception('HTTP 401 Unauthorized')); + + await coordinator.restoreSession(); + + verify(() => mockStorage.clear()).called(1); + }); + + test('concurrent calls are coalesced — only one restore attempt', () async { + when(() => mockUsp.isAuthenticated).thenReturn(false); + when(() => mockStorage.load()).thenReturn('stored-token'); + // Slow refresh to ensure concurrent calls overlap + when(() => mockUsp.refreshToken(token: 'stored-token')) .thenAnswer((_) => Future.delayed(const Duration(milliseconds: 50))); // Launch multiple concurrent calls @@ -345,24 +369,99 @@ void main() { ]; await Future.wait(futures); - // login should only be called once despite 3 concurrent calls - verify(() => mockUsp.login('storedPassword')).called(1); + // refreshToken should only be called once despite 3 concurrent calls + verify(() => mockUsp.refreshToken(token: 'stored-token')).called(1); }); - test('cooldown skips login within 5 seconds after failure', () async { + test('cooldown skips restore within 1 second after failure', () async { when(() => mockUsp.isAuthenticated).thenReturn(false); - when(() => mockStorage.read(key: any(named: 'key'))) - .thenAnswer((_) async => 'storedPassword'); - when(() => mockUsp.login(any())) + when(() => mockStorage.load()).thenReturn('stored-token'); + when(() => mockUsp.refreshToken(token: 'stored-token')) .thenThrow(Exception('Connection refused')); // First call — fails await coordinator.restoreSession(); - verify(() => mockUsp.login('storedPassword')).called(1); + verify(() => mockUsp.refreshToken(token: 'stored-token')).called(1); // Second call within cooldown — should be skipped await coordinator.restoreSession(); - verifyNever(() => mockUsp.login(any())); // No additional login call + verifyNever( + () => mockUsp.refreshToken(token: any(named: 'token'))); // No extra + }); + + test('triggers force logout when no stored token and not recovering', + () async { + when(() => mockUsp.isAuthenticated).thenReturn(false); + when(() => mockStorage.load()).thenReturn(null); + + bool logoutCalled = false; + coordinator.onForceLogout = () => logoutCalled = true; + + await coordinator.restoreSession(isRecovering: false); + + expect(logoutCalled, isTrue); + }); + + test('suppresses force logout when no stored token but is recovering', + () async { + when(() => mockUsp.isAuthenticated).thenReturn(false); + when(() => mockStorage.load()).thenReturn(null); + + bool logoutCalled = false; + coordinator.onForceLogout = () => logoutCalled = true; + + await coordinator.restoreSession(isRecovering: true); + + expect(logoutCalled, isFalse); + }); + + test('triggers force logout when token expired and not recovering', + () async { + when(() => mockUsp.isAuthenticated).thenReturn(false); + when(() => mockStorage.load()).thenReturn('expired-token'); + when(() => mockUsp.refreshToken(token: 'expired-token')) + .thenThrow(Exception('HTTP 401 Unauthorized')); + + bool logoutCalled = false; + coordinator.onForceLogout = () => logoutCalled = true; + + await coordinator.restoreSession(isRecovering: false); + + expect(logoutCalled, isTrue); + verify(() => mockStorage.clear()).called(1); + }); + + test('suppresses force logout when token expired but is recovering', + () async { + when(() => mockUsp.isAuthenticated).thenReturn(false); + when(() => mockStorage.load()).thenReturn('expired-token'); + when(() => mockUsp.refreshToken(token: 'expired-token')) + .thenThrow(Exception('HTTP 401 Unauthorized')); + + bool logoutCalled = false; + coordinator.onForceLogout = () => logoutCalled = true; + + await coordinator.restoreSession(isRecovering: true); + + expect(logoutCalled, isFalse); + // Token should still be cleared even in recovery mode + verify(() => mockStorage.clear()).called(1); + }); + + test('does not trigger force logout on network error (might recover)', + () async { + when(() => mockUsp.isAuthenticated).thenReturn(false); + when(() => mockStorage.load()).thenReturn('stored-token'); + when(() => mockUsp.refreshToken(token: 'stored-token')) + .thenThrow(Exception('Connection refused')); + + bool logoutCalled = false; + coordinator.onForceLogout = () => logoutCalled = true; + + await coordinator.restoreSession(isRecovering: false); + + // Network error — don't force logout, might recover + expect(logoutCalled, isFalse); }); }); @@ -380,7 +479,13 @@ void main() { // ensureAuth should now skip (timestamp was just set) await coordinator.ensureAuth(); - verifyNever(() => mockUsp.refreshToken()); + verifyNever(() => mockUsp.refreshToken(token: any(named: 'token'))); + }); + + test('onRefreshTokenSuccess persists token', () async { + mockUsp.onRefreshTokenSuccess!(); + + verify(() => mockStorage.save('test-token')).called(1); }); }); @@ -391,19 +496,144 @@ void main() { test('resets _lastTokenRefresh on 401 so next call retries immediately', () async { // Make refreshToken throw 401 - when(() => mockUsp.refreshToken()) + when(() => mockUsp.refreshToken(token: any(named: 'token'))) .thenThrow(Exception('HTTP 401 Unauthorized')); coordinator.onForceLogout = () {}; // ensureAuth gets 401 — should reset _lastTokenRefresh await coordinator.ensureAuth(); - verify(() => mockUsp.refreshToken()).called(1); + verify(() => mockUsp.refreshToken(token: null)).called(1); // Immediately call again — should attempt refresh because // _lastTokenRefresh was reset to null on 401 - when(() => mockUsp.refreshToken()).thenAnswer((_) async {}); + when(() => mockUsp.refreshToken(token: any(named: 'token'))) + .thenAnswer((_) async {}); + await coordinator.ensureAuth(); + verify(() => mockUsp.refreshToken(token: null)).called(1); + }); + }); + + // --------------------------------------------------------------------------- + // reloginWithNewPassword + // --------------------------------------------------------------------------- + group('reloginWithNewPassword', () { + test( + 'clears old token, logs out, logs in with new password, persists token', + () async { + when(() => mockUsp.logout()).thenAnswer((_) async {}); + when(() => mockUsp.login(any())).thenAnswer((_) async {}); + + await coordinator.reloginWithNewPassword('newPassword123'); + + // Verify sequence: clear → logout → login → persist + verifyInOrder([ + () => mockStorage.clear(), + () => mockUsp.logout(), + () => mockUsp.login('newPassword123'), + () => mockStorage.save('test-token'), + ]); + }); + + test('continues even if logout fails (best-effort)', () async { + when(() => mockUsp.logout()).thenThrow(Exception('Session expired')); + when(() => mockUsp.login(any())).thenAnswer((_) async {}); + + // Should not throw despite logout failure + await coordinator.reloginWithNewPassword('newPassword123'); + + verify(() => mockUsp.login('newPassword123')).called(1); + verify(() => mockStorage.save('test-token')).called(1); + }); + + test( + 'throws InvalidCredentialsError when login succeeds but isAuthenticated=false', + () async { + when(() => mockUsp.logout()).thenAnswer((_) async {}); + when(() => mockUsp.login(any())).thenAnswer((_) async {}); + when(() => mockUsp.isAuthenticated).thenReturn(false); + + await expectLater( + coordinator.reloginWithNewPassword('newPassword123'), + throwsA(isA()), + ); + }); + + test('throws UnexpectedError when login throws non-ServiceError', () async { + when(() => mockUsp.logout()).thenAnswer((_) async {}); + when(() => mockUsp.login(any())).thenThrow(Exception('Network failure')); + + await expectLater( + coordinator.reloginWithNewPassword('newPassword123'), + throwsA(isA()), + ); + }); + + test('rethrows ServiceError from login', () async { + when(() => mockUsp.logout()).thenAnswer((_) async {}); + when(() => mockUsp.login(any())) + .thenThrow(const NetworkError(detail: 'timeout')); + + await expectLater( + coordinator.reloginWithNewPassword('newPassword123'), + throwsA(isA()), + ); + }); + + test('updates _lastTokenRefresh on success', () async { + when(() => mockUsp.logout()).thenAnswer((_) async {}); + when(() => mockUsp.login(any())).thenAnswer((_) async {}); + + await coordinator.reloginWithNewPassword('newPassword123'); + + // Immediate ensureAuth should skip (timestamp just set) await coordinator.ensureAuth(); - verify(() => mockUsp.refreshToken()).called(1); + verifyNever(() => mockUsp.refreshToken(token: any(named: 'token'))); + }); + }); + + // --------------------------------------------------------------------------- + // Null UspClient guards + // --------------------------------------------------------------------------- + group('null UspClient guards', () { + late UspAuthCoordinator nullCoordinator; + + setUp(() { + nullCoordinator = UspAuthCoordinator(null, mockStorage); + }); + + test('reloginWithNewPassword throws ServiceNotInitializedError', () async { + await expectLater( + nullCoordinator.reloginWithNewPassword('password'), + throwsA(isA()), + ); + }); + + test('tryUspLogin throws ServiceNotInitializedError', () async { + await expectLater( + nullCoordinator.tryUspLogin('password'), + throwsA(isA()), + ); + }); + + test('restoreSession returns early without error', () async { + // Should not throw, just log warning + await nullCoordinator.restoreSession(); + verifyNever(() => mockStorage.load()); + }); + + test('syncAfterLocalLogin returns early without error', () async { + await nullCoordinator.syncAfterLocalLogin('password'); + verifyNever(() => mockStorage.save(any())); + }); + + test('syncAfterLogout clears storage even with null client', () async { + await nullCoordinator.syncAfterLogout(); + verify(() => mockStorage.clear()).called(1); + }); + + test('ensureAuth returns early without error', () async { + await nullCoordinator.ensureAuth(); + // No assertions needed — just verify no exception }); }); @@ -412,7 +642,7 @@ void main() { // --------------------------------------------------------------------------- group('_isAuthError pattern matching', () { test('matches actual WASM error format', () async { - when(() => mockUsp.refreshToken()).thenThrow( + when(() => mockUsp.refreshToken(token: any(named: 'token'))).thenThrow( Exception('Get failed: Transport error: HTTP error: HTTP 401')); bool logoutCalled = false; @@ -424,7 +654,7 @@ void main() { }); test('does NOT match HTTP 500', () async { - when(() => mockUsp.refreshToken()) + when(() => mockUsp.refreshToken(token: any(named: 'token'))) .thenThrow(Exception('HTTP 500 Internal Server Error')); bool logoutCalled = false; @@ -436,7 +666,7 @@ void main() { }); test('does NOT match HTTP 403', () async { - when(() => mockUsp.refreshToken()) + when(() => mockUsp.refreshToken(token: any(named: 'token'))) .thenThrow(Exception('HTTP error: HTTP 403 Forbidden')); bool logoutCalled = false; @@ -447,53 +677,4 @@ void main() { expect(logoutCalled, isFalse); }); }); - - // --------------------------------------------------------------------------- - // _loginWithStoredPassword — silent failure - // --------------------------------------------------------------------------- - group('_loginWithStoredPassword — silent failure', () { - test('restoreSession does not throw when login fails', () async { - when(() => mockUsp.isAuthenticated).thenReturn(false); - when(() => mockStorage.read(key: any(named: 'key'))) - .thenAnswer((_) async => 'storedPassword'); - when(() => mockUsp.login(any())) - .thenThrow(Exception('Connection refused')); - - // Should not throw - await coordinator.restoreSession(); - }); - - test('onReauthRequired does not throw when login fails', () async { - when(() => mockStorage.read(key: any(named: 'key'))) - .thenAnswer((_) async => 'storedPassword'); - when(() => mockUsp.login(any())) - .thenThrow(Exception('Connection refused')); - - // Trigger restoreSession via onReauthRequired wiring - final onReauth = mockUsp.onReauthRequired; - expect(onReauth, isNotNull); - - // Should not throw - await onReauth!(); - }); - - test('returns false and does not update timestamp on failure', () async { - when(() => mockUsp.isAuthenticated).thenReturn(false); - when(() => mockStorage.read(key: any(named: 'key'))) - .thenAnswer((_) async => 'storedPassword'); - when(() => mockUsp.login(any())) - .thenThrow(Exception('Connection refused')); - - await coordinator.restoreSession(); - - // Re-stub for ensureAuth check - when(() => mockUsp.isAuthenticated).thenReturn(true); - when(() => mockUsp.isReauthInProgress).thenReturn(false); - when(() => mockUsp.refreshToken()).thenAnswer((_) async {}); - - // ensureAuth should refresh (timestamp was NOT set) - await coordinator.ensureAuth(); - verify(() => mockUsp.refreshToken()).called(1); - }); - }); } diff --git a/test/core/utils/ipv6_address_test.dart b/test/core/utils/ipv6_address_test.dart new file mode 100644 index 000000000..3d9d71d38 --- /dev/null +++ b/test/core/utils/ipv6_address_test.dart @@ -0,0 +1,110 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:privacy_gui/core/utils/ipv6_address.dart'; + +void main() { + group('classifyIpv6Scope', () { + test('link-local fe80::/10 → linkLocal', () { + expect( + classifyIpv6Scope('fe80::7612:13ff:fe21:5394'), Ipv6Scope.linkLocal); + expect(classifyIpv6Scope('fe80:0000:0000:0000:139f:b9c2:6598:5d1e'), + Ipv6Scope.linkLocal); + // febf is the top of the fe80::/10 range. + expect(classifyIpv6Scope('febf::1'), Ipv6Scope.linkLocal); + }); + + test('global unicast 2000::/3 → global', () { + expect(classifyIpv6Scope('2401:e180:8801:d79d:7612:13ff:fe21:5394'), + Ipv6Scope.global); + expect(classifyIpv6Scope('2401:e180:8831:505f::1'), Ipv6Scope.global); + expect(classifyIpv6Scope('2001:db8::1'), Ipv6Scope.global); + // 3fff is the top of the 2000::/3 range. + expect(classifyIpv6Scope('3fff::1'), Ipv6Scope.global); + }); + + test('unique local fc00::/7 → uniqueLocal', () { + expect(classifyIpv6Scope('fc00::1'), Ipv6Scope.uniqueLocal); + expect(classifyIpv6Scope('fd12:3456::1'), Ipv6Scope.uniqueLocal); + }); + + test('non-classifiable / malformed → other', () { + expect(classifyIpv6Scope(''), Ipv6Scope.other); + expect(classifyIpv6Scope(' '), Ipv6Scope.other); + expect(classifyIpv6Scope('192.168.1.1'), Ipv6Scope.other); + expect(classifyIpv6Scope('::1'), Ipv6Scope.other); // loopback + expect(classifyIpv6Scope('ff02::1'), Ipv6Scope.other); // multicast + expect(classifyIpv6Scope('not-an-ip'), Ipv6Scope.other); + }); + + test('deprecated / reserved ranges inside 2000::/3 → other (W-1)', () { + // 3FFE::/16 — 6bone deprecated testing network (RFC 3701). Falls inside + // 2000::/3 by first byte but must NOT be surfaced as global unicast, + // matching IPv6WithReservedRule in validator_rules/rules.dart. + expect(classifyIpv6Scope('3ffe::1'), Ipv6Scope.other); + // 5F00::/12 and 6000::/3–7FFF::/3 reserved/unallocated. + expect(classifyIpv6Scope('5f00::1'), Ipv6Scope.other); + expect(classifyIpv6Scope('7000::1'), Ipv6Scope.other); + // 3fff (not 6bone) remains a valid global unicast — top of 2000::/3. + expect(classifyIpv6Scope('3fff::1'), Ipv6Scope.global); + }); + + test('handles zone id and prefix length suffixes', () { + expect(classifyIpv6Scope('fe80::1%eth0'), Ipv6Scope.linkLocal); + expect(classifyIpv6Scope('2401:e180::1/64'), Ipv6Scope.global); + }); + }); + + group('isGlobalUnicastIpv6', () { + test('true only for global unicast', () { + expect(isGlobalUnicastIpv6('2401:e180:8801:d79d::1'), isTrue); + expect(isGlobalUnicastIpv6('fe80::1'), isFalse); + expect(isGlobalUnicastIpv6('fc00::1'), isFalse); + expect(isGlobalUnicastIpv6(''), isFalse); + }); + }); + + group('preferGlobalIpv6First', () { + test('surfaces global unicast ahead of link-local (issue #1128 case)', () { + // Exact ordering from the #1128 diagnostic log + // (Device.IP.Interface.2.IPv6Address.1..4). + final input = [ + 'fe80::7612:13ff:fe21:5394', // instance 1 — link-local (the bug) + '2401:e180:8831:505f::1', // instance 2 — global + '2401:e180:8831:505f:7612:13ff:fe21:5394', // instance 3 — global + '2401:e180:8801:d79d:7612:13ff:fe21:5394', // instance 4 — global (WAN) + ]; + + final result = preferGlobalIpv6First(input); + + // First address must now be a global unicast, not the link-local. + expect(isGlobalUnicastIpv6(result.first), isTrue); + expect(result.first, '2401:e180:8831:505f::1'); + // Link-local sinks to the end. + expect(result.last, 'fe80::7612:13ff:fe21:5394'); + }); + + test('is stable within a scope (preserves instance order)', () { + final input = [ + '2401:e180:8831:505f::1', + '2401:e180:8831:505f:7612:13ff:fe21:5394', + '2401:e180:8801:d79d:7612:13ff:fe21:5394', + ]; + // All global → order unchanged. + expect(preferGlobalIpv6First(input), input); + }); + + test('orders global > ULA > link-local > other', () { + final input = [ + 'fe80::1', // link-local + 'ff02::1', // other (multicast) + 'fc00::1', // ULA + '2001:db8::1', // global + ]; + expect(preferGlobalIpv6First(input), + ['2001:db8::1', 'fc00::1', 'fe80::1', 'ff02::1']); + }); + + test('empty input returns empty', () { + expect(preferGlobalIpv6First(const []), isEmpty); + }); + }); +} diff --git a/test/core/utils/state_log_observer_test.dart b/test/core/utils/state_log_observer_test.dart new file mode 100644 index 000000000..14864b18f --- /dev/null +++ b/test/core/utils/state_log_observer_test.dart @@ -0,0 +1,357 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:privacy_gui/core/utils/logger.dart'; +import 'package:privacy_gui/core/utils/state_log_observer.dart'; +import 'package:privacy_gui/framework/diagnostic_loggable.dart'; + +// --- Test Models --- + +class TestLoggableState extends Equatable with DiagnosticLoggable { + final String value; + + const TestLoggableState(this.value); + + @override + Map get namedProps => {'value': value}; +} + +class TestSensitiveState extends Equatable with DiagnosticLoggable { + final String macAddress; + final String serialNumber; + final String password; + + const TestSensitiveState({ + required this.macAddress, + required this.serialNumber, + required this.password, + }); + + @override + Map get namedProps => { + 'macAddress': macAddress, + 'serialNumber': serialNumber, + 'password': password, + }; +} + +class TestNonLoggableState extends Equatable with DiagnosticLoggable { + final String value; + + const TestNonLoggableState(this.value); + + @override + Map get namedProps => {'value': value}; + + @override + bool get loggable => false; +} + +class TestPlainState extends Equatable { + final String value; + + const TestPlainState(this.value); + + @override + List get props => [value]; +} + +// --- Test Providers (Async) --- + +final asyncLoggableProvider = + AsyncNotifierProvider( + AsyncLoggableNotifier.new, +); + +class AsyncLoggableNotifier extends AsyncNotifier { + @override + Future build() async { + return const TestLoggableState('initial'); + } + + void setValue(String value) { + state = AsyncData(TestLoggableState(value)); + } +} + +final asyncNonLoggableProvider = + AsyncNotifierProvider( + AsyncNonLoggableNotifier.new, +); + +class AsyncNonLoggableNotifier extends AsyncNotifier { + @override + Future build() async { + return const TestNonLoggableState('initial'); + } + + void setValue(String value) { + state = AsyncData(TestNonLoggableState(value)); + } +} + +final asyncPlainProvider = + AsyncNotifierProvider( + AsyncPlainNotifier.new, +); + +class AsyncPlainNotifier extends AsyncNotifier { + @override + Future build() async { + return const TestPlainState('initial'); + } +} + +// --- Test Providers (Sync) --- + +final syncLoggableProvider = + NotifierProvider( + SyncLoggableNotifier.new, +); + +class SyncLoggableNotifier extends Notifier { + @override + TestLoggableState build() { + return const TestLoggableState('sync-initial'); + } + + void setValue(String value) { + state = TestLoggableState(value); + } +} + +// --- Test Helpers --- + +/// Tracks calls to updateStateLog for verification +class StateLogTracker { + final List<(String, String)> calls = []; + + void track(String typeName, String state) { + calls.add((typeName, state)); + } + + void clear() => calls.clear(); + + bool hasCall(String typeName) => calls.any((c) => c.$1 == typeName); + + String? getState(String typeName) => + calls.where((c) => c.$1 == typeName).lastOrNull?.$2; +} + +// --- Tests --- + +void main() { + group('StateLogObserver', () { + late StateLogObserver observer; + late ProviderContainer container; + + setUp(() { + clearStateLogCacheForTest(); + observer = StateLogObserver(); + container = ProviderContainer(observers: [observer]); + }); + + tearDown(() { + container.dispose(); + clearStateLogCacheForTest(); + }); + + group('AsyncValue providers', () { + test('captures DiagnosticLoggable state with loggable=true', () async { + // Trigger build and wait + await container.read(asyncLoggableProvider.future); + + // Verify initial state is cached + expect(stateLogCacheForTest['TestLoggableState'], isNotNull); + expect( + stateLogCacheForTest['TestLoggableState'], + equals('{"value":"initial"}'), + ); + + // Update state + container.read(asyncLoggableProvider.notifier).setValue('updated'); + + // Verify updated state is cached + expect( + stateLogCacheForTest['TestLoggableState'], + equals('{"value":"updated"}'), + ); + }); + + test('skips DiagnosticLoggable state with loggable=false', () async { + await container.read(asyncNonLoggableProvider.future); + container.read(asyncNonLoggableProvider.notifier).setValue('updated'); + + // Observer should skip due to loggable=false — not in cache + expect(stateLogCacheForTest['TestNonLoggableState'], isNull); + }); + + test('skips non-DiagnosticLoggable state', () async { + await container.read(asyncPlainProvider.future); + + // Observer should skip because TestPlainState doesn't have mixin + expect(stateLogCacheForTest['TestPlainState'], isNull); + }); + + test('skips AsyncLoading state', () async { + // Read provider while it's loading (don't await) + container.read(asyncLoggableProvider); + + // At this point state is AsyncLoading — should not be cached yet + // (cache should be empty since loading state is skipped) + expect(stateLogCacheForTest['TestLoggableState'], isNull); + }); + + test('skips AsyncError state', () async { + // Create a provider that errors + final errorProvider = + AsyncNotifierProvider( + ErrorNotifier.new, + ); + final errorContainer = ProviderContainer(observers: [observer]); + + try { + await errorContainer.read(errorProvider.future); + } catch (_) { + // Expected + } + + // Observer should skip error states — not in cache + expect(stateLogCacheForTest['TestLoggableState'], isNull); + + errorContainer.dispose(); + }); + }); + + group('Sync providers', () { + test('captures sync DiagnosticLoggable state on update', () { + // Read triggers build — but didUpdateProvider is NOT called on initial build + final state = container.read(syncLoggableProvider); + expect(state.value, equals('sync-initial')); + + // Initial state is NOT cached (didUpdateProvider only fires on state changes) + expect(stateLogCacheForTest['TestLoggableState'], isNull); + + // Update — this triggers didUpdateProvider + container.read(syncLoggableProvider.notifier).setValue('sync-updated'); + + // Now updated state is cached + expect( + stateLogCacheForTest['TestLoggableState'], + equals('{"value":"sync-updated"}'), + ); + }); + }); + + group('didUpdateProvider behavior', () { + test('extracts value from AsyncData correctly', () async { + await container.read(asyncLoggableProvider.future); + + final state = container.read(asyncLoggableProvider); + expect(state.hasValue, isTrue); + expect(state.value?.value, equals('initial')); + }); + + test('handles null value in AsyncData', () async { + // Create provider that can have null + final nullableProvider = + AsyncNotifierProvider( + NullableNotifier.new); + final nullContainer = ProviderContainer(observers: [observer]); + + await nullContainer.read(nullableProvider.future); + + // Observer should skip null values — cache should be empty + expect(stateLogCacheForTest.isEmpty, isTrue); + + nullContainer.dispose(); + }); + }); + }); + + group('Integration: DiagnosticLoggable + StateLogObserver', () { + test('complex nested state produces valid JSON', () async { + final container = ProviderContainer(observers: [StateLogObserver()]); + + await container.read(asyncLoggableProvider.future); + + final state = container.read(asyncLoggableProvider).value!; + final json = state.toString(); + + expect(json, equals('{"value":"initial"}')); + + container.dispose(); + }); + }); + + group('Sensitive data masking', () { + late StateLogObserver observer; + late ProviderContainer container; + + setUp(() { + clearStateLogCacheForTest(); + observer = StateLogObserver(); + container = ProviderContainer(observers: [observer]); + }); + + tearDown(() { + container.dispose(); + clearStateLogCacheForTest(); + }); + + test('masks MAC addresses in state log cache', () async { + // Use async provider so initial build triggers didUpdateProvider + final sensitiveProvider = + AsyncNotifierProvider( + AsyncSensitiveNotifier.new, + ); + + // Await initial build — this triggers didUpdateProvider for async providers + await container.read(sensitiveProvider.future); + + final cached = stateLogCacheForTest['TestSensitiveState']; + expect(cached, isNotNull); + + // MAC address should be masked (XX:XX:XX:XX:EE:FF pattern) + expect(cached, contains('XX:XX:XX:XX')); + expect(cached, isNot(contains('AA:BB:CC:DD'))); + + // Serial number should be masked (only last 4 visible) + expect(cached, contains('****5678')); + expect(cached, isNot(contains('SN12345678'))); + + // Password should be masked + expect(cached, contains('***')); + expect(cached, isNot(contains('secret123'))); + }); + }); +} + +// --- Additional Test Notifiers --- + +class AsyncSensitiveNotifier extends AsyncNotifier { + @override + Future build() async { + return const TestSensitiveState( + macAddress: 'AA:BB:CC:DD:EE:FF', + serialNumber: 'SN12345678', + password: 'secret123', + ); + } +} + +// --- Additional Test Notifiers --- + +class ErrorNotifier extends AsyncNotifier { + @override + Future build() async { + throw Exception('Test error'); + } +} + +class NullableNotifier extends AsyncNotifier { + @override + Future build() async { + return null; + } +} diff --git a/test/core/utils/wifi_channel_test.dart b/test/core/utils/wifi_channel_test.dart new file mode 100644 index 000000000..b95831ed9 --- /dev/null +++ b/test/core/utils/wifi_channel_test.dart @@ -0,0 +1,124 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:privacy_gui/core/utils/wifi_channel.dart'; + +void main() { + // --------------------------------------------------------------------------- + // parsePossibleChannels — range notation, sentinels, malformed tokens + // --------------------------------------------------------------------------- + + group('parsePossibleChannels', () { + test('empty string returns empty list', () { + expect(parsePossibleChannels(''), isEmpty); + }); + + test('parses comma-separated single values ("1,6,11")', () { + expect(parsePossibleChannels('1,6,11'), [1, 6, 11]); + }); + + test('expands mixed range + single notation ("1-3,6")', () { + expect(parsePossibleChannels('1-3,6'), [1, 2, 3, 6]); + }); + + test('expands full range notation ("1-13")', () { + expect( + parsePossibleChannels('1-13'), + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], + ); + }); + + test('sorts unordered input', () { + expect(parsePossibleChannels('11,1,6'), [1, 6, 11]); + }); + + test('inverted range ("11-1") degrades to empty without throwing', () { + expect(parsePossibleChannels('11-1'), isEmpty); + }); + + test('filters out TR-181 "0" auto/any sentinel ("0,1,6,11")', () { + expect(parsePossibleChannels('0,1,6,11'), [1, 6, 11]); + }); + + test('drops non-positive channels from a range ("0-2")', () { + expect(parsePossibleChannels('0-2'), [1, 2]); + }); + + test('skips malformed range token ("1-2-3") without throwing', () { + expect(parsePossibleChannels('1-2-3'), isEmpty); + }); + + test('ignores whitespace around tokens (" 1 , 6 , 11 ")', () { + expect(parsePossibleChannels(' 1 , 6 , 11 '), [1, 6, 11]); + }); + }); + + // --------------------------------------------------------------------------- + // isDfsChannel — 5 GHz DFS classification + // --------------------------------------------------------------------------- + + group('isDfsChannel', () { + test('5 GHz DFS channels are DFS (52, 64, 100, 144)', () { + for (final ch in [52, 56, 60, 64, 100, 140, 144]) { + expect(isDfsChannel(ch, band: '5GHz'), isTrue, reason: 'channel $ch'); + } + }); + + test('5 GHz non-DFS channels are not DFS (36, 40, 44, 48, 149)', () { + for (final ch in [36, 40, 44, 48, 149]) { + expect(isDfsChannel(ch, band: '5GHz'), isFalse, reason: 'channel $ch'); + } + }); + + test('2.4 GHz channels are never DFS', () { + for (final ch in [1, 6, 11, 52, 100]) { + expect(isDfsChannel(ch, band: '2.4GHz'), isFalse); + } + }); + + test('6 GHz channels are never DFS', () { + expect(isDfsChannel(52, band: '6GHz'), isFalse); + expect(isDfsChannel(100, band: '6GHz'), isFalse); + }); + }); + + // --------------------------------------------------------------------------- + // filterDfsChannels — hide DFS channels when DFS disabled + // --------------------------------------------------------------------------- + + group('filterDfsChannels', () { + const fiveGhzAll = [ + 36, 40, 44, 48, // + 52, 56, 60, 64, // + 100, 104, 108, 112, 116, 120, 124, 128, 132, 136, 140, + ]; + + test('DFS disabled on 5 GHz drops DFS channels', () { + expect( + filterDfsChannels(fiveGhzAll, band: '5GHz', dfsEnabled: false), + [36, 40, 44, 48], + ); + }); + + test('DFS enabled on 5 GHz keeps all channels', () { + expect( + filterDfsChannels(fiveGhzAll, band: '5GHz', dfsEnabled: true), + fiveGhzAll, + ); + }); + + test('2.4 GHz is untouched regardless of DFS state', () { + const ch = [1, 6, 11]; + expect(filterDfsChannels(ch, band: '2.4GHz', dfsEnabled: false), ch); + expect(filterDfsChannels(ch, band: '2.4GHz', dfsEnabled: true), ch); + }); + + test('6 GHz is untouched regardless of DFS state', () { + const ch = [1, 5, 9, 213]; + expect(filterDfsChannels(ch, band: '6GHz', dfsEnabled: false), ch); + }); + + test('empty list stays empty', () { + expect(filterDfsChannels(const [], band: '5GHz', dfsEnabled: false), + isEmpty); + }); + }); +} diff --git a/test/framework/diagnostic_loggable_test.dart b/test/framework/diagnostic_loggable_test.dart new file mode 100644 index 000000000..f4966088a --- /dev/null +++ b/test/framework/diagnostic_loggable_test.dart @@ -0,0 +1,478 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:privacy_gui/framework/diagnostic_loggable.dart'; + +// --- Test Models --- + +/// Simple model with primitive types +class SimpleModel extends Equatable with DiagnosticLoggable { + final String name; + final int count; + final bool active; + + const SimpleModel({ + required this.name, + required this.count, + required this.active, + }); + + @override + Map get namedProps => { + 'name': name, + 'count': count, + 'active': active, + }; +} + +/// Model with nullable fields +class NullableModel extends Equatable with DiagnosticLoggable { + final String? label; + final int? value; + + const NullableModel({this.label, this.value}); + + @override + Map get namedProps => { + 'label': label, + 'value': value, + }; +} + +/// Model with nested DiagnosticLoggable +class NestedModel extends Equatable with DiagnosticLoggable { + final SimpleModel child; + + const NestedModel({required this.child}); + + @override + Map get namedProps => {'child': child}; +} + +/// Model with list of DiagnosticLoggable +class ListModel extends Equatable with DiagnosticLoggable { + final List items; + + const ListModel({required this.items}); + + @override + Map get namedProps => {'items': items}; +} + +/// Model with map values +class MapModel extends Equatable with DiagnosticLoggable { + final Map itemMap; + + const MapModel({required this.itemMap}); + + @override + Map get namedProps => {'itemMap': itemMap}; +} + +/// Model with DateTime +class DateTimeModel extends Equatable with DiagnosticLoggable { + final DateTime timestamp; + + const DateTimeModel({required this.timestamp}); + + @override + Map get namedProps => {'timestamp': timestamp}; +} + +/// Model with Duration +class DurationModel extends Equatable with DiagnosticLoggable { + final Duration duration; + + const DurationModel({required this.duration}); + + @override + Map get namedProps => {'duration': duration}; +} + +/// Test enum +enum TestStatus { active, inactive, pending } + +/// Model with enum +class EnumModel extends Equatable with DiagnosticLoggable { + final TestStatus status; + + const EnumModel({required this.status}); + + @override + Map get namedProps => {'status': status}; +} + +/// Model with Map (non-string keys) +class NonStringKeyMapModel extends Equatable with DiagnosticLoggable { + final Map data; + + const NonStringKeyMapModel({required this.data}); + + @override + Map get namedProps => {'data': data}; +} + +/// Model with loggable = false +class NonLoggableModel extends Equatable with DiagnosticLoggable { + final String data; + + const NonLoggableModel({required this.data}); + + @override + Map get namedProps => {'data': data}; + + @override + bool get loggable => false; +} + +/// Model simulating Data provider pattern (wraps UIModel) +class TestUIModel extends Equatable with DiagnosticLoggable { + final String field1; + final int field2; + + const TestUIModel({required this.field1, required this.field2}); + + @override + Map get namedProps => { + 'field1': field1, + 'field2': field2, + }; +} + +class TestData extends Equatable with DiagnosticLoggable { + final TestUIModel model; + + const TestData({required this.model}); + + @override + Map get namedProps => {'model': model}; +} + +/// Model built with `with EquatableMixin` (not `extends Equatable`) — mirrors +/// the MeshNetwork entities. Uses [DiagnosticNamed] since it cannot mix in +/// [DiagnosticLoggable] (constrained `on Equatable`). Keeps its own [props]. +class MixinModel with EquatableMixin, DiagnosticNamed { + final String id; + final int level; + + const MixinModel({required this.id, required this.level}); + + @override + List get props => [id, level]; + + @override + Map get namedProps => {'id': id, 'level': level}; +} + +/// DiagnosticLoggable that nests a DiagnosticNamed (EquatableMixin) child. +class LoggableWrappingMixin extends Equatable with DiagnosticLoggable { + final MixinModel child; + + const LoggableWrappingMixin({required this.child}); + + @override + Map get namedProps => {'child': child}; +} + +/// DiagnosticLoggable that nests a list of DiagnosticNamed children. +class LoggableWrappingList extends Equatable with DiagnosticLoggable { + final List items; + + const LoggableWrappingList({required this.items}); + + @override + Map get namedProps => {'items': items}; +} + +// --- Tests --- + +void main() { + group('DiagnosticLoggable', () { + group('namedProps and props', () { + test('props derived from namedProps values', () { + const model = SimpleModel(name: 'test', count: 42, active: true); + + expect(model.props, equals(['test', 42, true])); + }); + + test('Equatable equality uses namedProps', () { + const model1 = SimpleModel(name: 'test', count: 42, active: true); + const model2 = SimpleModel(name: 'test', count: 42, active: true); + const model3 = SimpleModel(name: 'other', count: 42, active: true); + + expect(model1, equals(model2)); + expect(model1, isNot(equals(model3))); + }); + + test('Map props equality - same content different instances', () { + // Two MapModel instances with identical Map content but different + // Map instances should be equal (Equatable uses deep comparison) + final map1 = MapModel(itemMap: { + 'a': const SimpleModel(name: 'first', count: 1, active: true), + 'b': const SimpleModel(name: 'second', count: 2, active: false), + }); + final map2 = MapModel(itemMap: { + 'a': const SimpleModel(name: 'first', count: 1, active: true), + 'b': const SimpleModel(name: 'second', count: 2, active: false), + }); + + // Verify maps are different instances + expect(identical(map1.itemMap, map2.itemMap), isFalse); + + // But models should be equal (Equatable deep comparison) + expect(map1, equals(map2)); + expect(map1.hashCode, equals(map2.hashCode)); + }); + + test('Map props equality - different content', () { + final map1 = MapModel(itemMap: { + 'a': const SimpleModel(name: 'first', count: 1, active: true), + }); + final map2 = MapModel(itemMap: { + 'a': const SimpleModel(name: 'different', count: 1, active: true), + }); + + expect(map1, isNot(equals(map2))); + }); + + test('Map props equality - different keys', () { + final map1 = MapModel(itemMap: { + 'a': const SimpleModel(name: 'first', count: 1, active: true), + }); + final map2 = MapModel(itemMap: { + 'b': const SimpleModel(name: 'first', count: 1, active: true), + }); + + expect(map1, isNot(equals(map2))); + }); + + test('Map props equality - empty maps', () { + final map1 = MapModel(itemMap: {}); + final map2 = MapModel(itemMap: {}); + + expect(map1, equals(map2)); + }); + }); + + group('toString JSON output', () { + test('simple model outputs valid JSON', () { + const model = SimpleModel(name: 'test', count: 42, active: true); + + final json = model.toString(); + + expect(json, equals('{"name":"test","count":42,"active":true}')); + }); + + test('nullable fields with null values', () { + const model = NullableModel(); + + final json = model.toString(); + + expect(json, equals('{"label":null,"value":null}')); + }); + + test('nullable fields with values', () { + const model = NullableModel(label: 'test', value: 123); + + final json = model.toString(); + + expect(json, equals('{"label":"test","value":123}')); + }); + + test('nested DiagnosticLoggable outputs nested JSON', () { + const model = NestedModel( + child: SimpleModel(name: 'inner', count: 1, active: false), + ); + + final json = model.toString(); + + expect( + json, + equals('{"child":{"name":"inner","count":1,"active":false}}'), + ); + }); + + test('list of DiagnosticLoggable outputs array', () { + const model = ListModel(items: [ + SimpleModel(name: 'a', count: 1, active: true), + SimpleModel(name: 'b', count: 2, active: false), + ]); + + final json = model.toString(); + + expect( + json, + equals( + '{"items":[{"name":"a","count":1,"active":true},{"name":"b","count":2,"active":false}]}', + ), + ); + }); + + test('empty list outputs empty array', () { + const model = ListModel(items: []); + + final json = model.toString(); + + expect(json, equals('{"items":[]}')); + }); + + test('map with DiagnosticLoggable values', () { + const model = MapModel(itemMap: { + 'first': SimpleModel(name: 'a', count: 1, active: true), + }); + + final json = model.toString(); + + expect( + json, + equals('{"itemMap":{"first":{"name":"a","count":1,"active":true}}}'), + ); + }); + + test('DateTime outputs ISO8601 string', () { + final model = DateTimeModel( + timestamp: DateTime.utc(2026, 7, 3, 12, 30, 45), + ); + + final json = model.toString(); + + expect(json, equals('{"timestamp":"2026-07-03T12:30:45.000Z"}')); + }); + + test('Duration outputs milliseconds', () { + const model = DurationModel(duration: Duration(seconds: 30)); + + final json = model.toString(); + + expect(json, equals('{"duration":30000}')); + }); + + test('enum outputs name string', () { + const model = EnumModel(status: TestStatus.active); + + final json = model.toString(); + + expect(json, equals('{"status":"active"}')); + }); + + test('non-string map keys convert to string', () { + const model = NonStringKeyMapModel(data: {1: 'one', 2: 'two'}); + + final json = model.toString(); + + expect(json, equals('{"data":{"1":"one","2":"two"}}')); + }); + + test('Data wrapping UIModel pattern', () { + const data = TestData( + model: TestUIModel(field1: 'value', field2: 99), + ); + + final json = data.toString(); + + expect( + json, + equals('{"model":{"field1":"value","field2":99}}'), + ); + }); + }); + + group('loggable flag', () { + test('default loggable is true', () { + const model = SimpleModel(name: 'test', count: 0, active: false); + + expect(model.loggable, isTrue); + }); + + test('can override loggable to false', () { + const model = NonLoggableModel(data: 'secret'); + + expect(model.loggable, isFalse); + }); + + test('toString still works when loggable is false', () { + const model = NonLoggableModel(data: 'secret'); + + expect(model.toString(), equals('{"data":"secret"}')); + }); + }); + + group('DiagnosticNamed (EquatableMixin models)', () { + test('EquatableMixin model with DiagnosticNamed outputs keyed JSON', () { + const model = MixinModel(id: 'x1', level: 3); + + expect(model.toString(), equals('{"id":"x1","level":3}')); + }); + + test('DiagnosticNamed keeps its own props for equality', () { + // namedProps and props are independent — props still drives equality. + const a = MixinModel(id: 'x1', level: 3); + const b = MixinModel(id: 'x1', level: 3); + const c = MixinModel(id: 'x1', level: 9); + + expect(a, equals(b)); + expect(a, isNot(equals(c))); + expect(a.props, equals(['x1', 3])); + }); + + test( + 'DiagnosticNamed nested inside DiagnosticLoggable serializes with keys ' + '(not a props array or "Instance of")', () { + const outer = LoggableWrappingMixin( + child: MixinModel(id: 'inner', level: 1), + ); + + final json = outer.toString(); + + expect(json, equals('{"child":{"id":"inner","level":1}}')); + expect(json, isNot(contains('Instance of'))); + }); + + test('list of DiagnosticNamed nested in DiagnosticLoggable', () { + const outer = LoggableWrappingList(items: [ + MixinModel(id: 'a', level: 1), + MixinModel(id: 'b', level: 2), + ]); + + expect( + outer.toString(), + equals('{"items":[{"id":"a","level":1},{"id":"b","level":2}]}'), + ); + }); + }); + + group('edge cases', () { + test('special characters in string values', () { + const model = SimpleModel( + name: 'test "quoted" value', + count: 0, + active: false, + ); + + final json = model.toString(); + + expect(json, contains(r'\"quoted\"')); + }); + + test('unicode characters in string values', () { + const model = SimpleModel(name: '測試', count: 0, active: false); + + final json = model.toString(); + + expect(json, contains('測試')); + }); + + test('deeply nested structure', () { + const model = NestedModel( + child: SimpleModel(name: 'level1', count: 1, active: true), + ); + final outer = ListModel(items: [model.child]); + + final json = outer.toString(); + + expect(json, isNotEmpty); + expect(json, startsWith('{')); + expect(json, endsWith('}')); + }); + }); + }); +} diff --git a/test/golden_test/golden_framework/golden_runner.dart b/test/golden_test/golden_framework/golden_runner.dart index 5cf129ecc..c5b60fcd6 100644 --- a/test/golden_test/golden_framework/golden_runner.dart +++ b/test/golden_test/golden_framework/golden_runner.dart @@ -90,7 +90,17 @@ void runViewGoldenTests(GoldenTestConfig config) { await tester.binding.setSurfaceSize(effectiveSize); tester.view.physicalSize = effectiveSize; tester.view.devicePixelRatio = 1.0; - await tester.pumpWidget(widget); + // Wrap with Localizations so dialogs using root navigator + // (showGeneralDialog with useRootNavigator: true) can access + // AppLocalizations. The widget tree from alchemist's wrapper + // doesn't include our app's Localizations. + await tester.pumpWidget( + Localizations( + locale: locale, + delegates: AppLocalizations.localizationsDelegates, + child: widget, + ), + ); }, builder: () => _buildGoldenWidget( config.view(), @@ -140,7 +150,16 @@ void runViewGoldenTests(GoldenTestConfig config) { await tester.binding.setSurfaceSize(effectiveSize); tester.view.physicalSize = effectiveSize; tester.view.devicePixelRatio = 1.0; - await tester.pumpWidget(widget); + // Wrap with Localizations so dialogs using root navigator + // (showGeneralDialog with useRootNavigator: true) can access + // AppLocalizations. + await tester.pumpWidget( + Localizations( + locale: locale, + delegates: AppLocalizations.localizationsDelegates, + child: widget, + ), + ); }, builder: () => _buildGoldenWidget( config.view(), diff --git a/test/golden_test/golden_framework/mocks/mock_devices.dart b/test/golden_test/golden_framework/mocks/mock_devices.dart index c1ab450c9..4b763530e 100644 --- a/test/golden_test/golden_framework/mocks/mock_devices.dart +++ b/test/golden_test/golden_framework/mocks/mock_devices.dart @@ -1,6 +1,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; import 'package:privacy_gui/page/_shared/models/dhcp_reservation_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_network.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; import 'package:privacy_gui/page/devices/providers/device_detail_provider.dart'; import 'package:privacy_gui/page/devices/providers/device_filter_provider.dart'; import 'package:privacy_gui/page/devices/providers/device_filter_state.dart'; @@ -25,15 +27,30 @@ class FixedDhcpDataNotifier extends DhcpDataNotifier { Future build() async => _fixedData; } +/// Creates a minimal MeshNetwork from a list of ClientDevices. +MeshNetwork _meshNetworkFromClients(List clients) { + return MeshNetwork( + master: MasterNode( + deviceId: 'GATEWAY', + model: 'Test Router', + manufacturer: 'Test', + serialNumber: 'SN123', + softwareVersion: '1.0.0', + connectedClients: clients, + ), + slaves: [], + ); +} + List devicesListOverrides({ - required List devices, + required List devices, DeviceFilterConfig filter = const DeviceFilterConfig(), DeviceFilterOptions options = const DeviceFilterOptions(), }) => [ devicesDataProvider.overrideWith( () => FixedDevicesDataNotifier( - DevicesData(deviceModels: devices), + DevicesData(meshNetwork: _meshNetworkFromClients(devices)), ), ), filteredDeviceListProvider.overrideWith((ref) => devices), @@ -57,7 +74,21 @@ List deviceDetailOverrides({ (ref, mac) => detail, ), devicesDataProvider.overrideWith( - () => FixedDevicesDataNotifier(const DevicesData()), + () => FixedDevicesDataNotifier( + DevicesData( + meshNetwork: MeshNetwork( + master: MasterNode( + deviceId: 'GATEWAY', + model: 'Test Router', + manufacturer: 'Test', + serialNumber: 'SN123', + softwareVersion: '1.0.0', + connectedClients: [], + ), + slaves: [], + ), + ), + ), ), dhcpDataProvider.overrideWith( () => FixedDhcpDataNotifier( diff --git a/test/golden_test/golden_framework/mocks/mock_dhcp.dart b/test/golden_test/golden_framework/mocks/mock_dhcp.dart index e041ea6ba..3b44579ef 100644 --- a/test/golden_test/golden_framework/mocks/mock_dhcp.dart +++ b/test/golden_test/golden_framework/mocks/mock_dhcp.dart @@ -1,6 +1,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:privacy_gui/page/_shared/models/dhcp_client_ui_model.dart'; import 'package:privacy_gui/page/_shared/models/lan_info_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_network.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; import 'package:privacy_gui/page/dhcp/models/dhcp_reservation_list.dart'; import 'package:privacy_gui/page/dhcp/models/dhcp_reservations_feature_state.dart'; @@ -60,7 +62,11 @@ class FixedDhcpDataNotifier extends DhcpDataNotifier { class FixedDevicesDataNotifier extends DevicesDataNotifier { @override - Future build() async => const DevicesData(); + Future build() async => DevicesData( + meshNetwork: MeshNetwork( + master: MasterNode(deviceId: 'GATEWAY', model: 'Test Router'), + ), + ); } List dhcpDetailOverrides({ diff --git a/test/golden_test/golden_framework/mocks/mock_ipv6_port_service.dart b/test/golden_test/golden_framework/mocks/mock_ipv6_port_service.dart index b7bc9a5d6..afb8e6a5b 100644 --- a/test/golden_test/golden_framework/mocks/mock_ipv6_port_service.dart +++ b/test/golden_test/golden_framework/mocks/mock_ipv6_port_service.dart @@ -1,4 +1,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_network.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; import 'package:privacy_gui/page/ipv6_port_service/models/ipv6_port_service_feature_state.dart'; import 'package:privacy_gui/page/ipv6_port_service/models/ipv6_port_service_rule_list.dart'; @@ -39,7 +41,11 @@ class FixedIpv6PortServiceNotifier extends UspIpv6PortServiceNotifier { class FixedDevicesDataNotifier extends DevicesDataNotifier { @override - Future build() async => const DevicesData(); + Future build() async => DevicesData( + meshNetwork: MeshNetwork( + master: MasterNode(deviceId: 'GATEWAY', model: 'Test Router'), + ), + ); } List ipv6PortServiceOverrides(Ipv6PortServiceFeatureState state) => [ diff --git a/test/golden_test/golden_framework/mocks/mock_statistics.dart b/test/golden_test/golden_framework/mocks/mock_statistics.dart index 55f42bab0..a7cb4d217 100644 --- a/test/golden_test/golden_framework/mocks/mock_statistics.dart +++ b/test/golden_test/golden_framework/mocks/mock_statistics.dart @@ -6,6 +6,8 @@ import 'package:privacy_gui/page/_shared/providers/usp_device_analytics_notifier import 'package:privacy_gui/page/_shared/providers/usp_system_monitor_notifier.dart'; import 'package:privacy_gui/page/_shared/providers/usp_traffic_analysis_notifier.dart'; import 'package:privacy_gui/page/admin/providers/system_info_data_provider.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_network.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; import 'package:privacy_gui/page/firewall/providers/firewall_data_provider.dart'; import 'package:privacy_gui/page/port_forwarding/providers/port_forwarding_data_provider.dart'; @@ -85,7 +87,11 @@ class FixedWifiDataNotifier extends WifiDataNotifier { class FixedDevicesDataNotifierForStats extends DevicesDataNotifier { @override - Future build() async => const DevicesData(); + Future build() async => DevicesData( + meshNetwork: MeshNetwork( + master: MasterNode(deviceId: 'GATEWAY', model: 'Test Router'), + ), + ); } List statisticsOverrides({ diff --git a/test/golden_test/golden_framework/mocks/mock_topology.dart b/test/golden_test/golden_framework/mocks/mock_topology.dart index 6fea8811d..f78512ac2 100644 --- a/test/golden_test/golden_framework/mocks/mock_topology.dart +++ b/test/golden_test/golden_framework/mocks/mock_topology.dart @@ -1,4 +1,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_network.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; import 'package:privacy_gui/page/admin/providers/system_info_data_provider.dart'; import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; import 'package:privacy_gui/page/topology/providers/node_detail_provider.dart'; @@ -40,6 +42,12 @@ List topologyViewOverrides({ List nodeDetailOverrides(UspNodeDetailState state) => [ uspNodeDetailProvider.overrideWith((ref, deviceId) => state), devicesDataProvider.overrideWith( - () => FixedDevicesDataNotifierForTopology(const DevicesData()), + () => FixedDevicesDataNotifierForTopology( + DevicesData( + meshNetwork: MeshNetwork( + master: MasterNode(deviceId: 'GATEWAY', model: 'Test Router'), + ), + ), + ), ), ]; diff --git a/test/golden_test/page/dashboard/cards/fixtures/cards_test_data.dart b/test/golden_test/page/dashboard/cards/fixtures/cards_test_data.dart index 2eb5ca1e4..feb25e5bc 100644 --- a/test/golden_test/page/dashboard/cards/fixtures/cards_test_data.dart +++ b/test/golden_test/page/dashboard/cards/fixtures/cards_test_data.dart @@ -1,11 +1,12 @@ import 'package:privacy_gui/page/_shared/models/client_connection_detail.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; import 'package:privacy_gui/page/_shared/models/device_analytics_state.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; import 'package:privacy_gui/page/_shared/models/dhcp_client_ui_model.dart'; import 'package:privacy_gui/page/_shared/models/dhcp_reservation_ui_model.dart'; import 'package:privacy_gui/page/_shared/models/ethernet_port_ui_model.dart'; import 'package:privacy_gui/page/_shared/models/lan_info_ui_model.dart'; -import 'package:privacy_gui/page/_shared/models/mesh_topology_info.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_network.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; import 'package:privacy_gui/page/_shared/models/port_forwarding_rule_ui_model.dart'; import 'package:privacy_gui/page/_shared/models/system_info_ui_model.dart'; import 'package:privacy_gui/page/_shared/models/system_monitor_state.dart'; @@ -13,6 +14,7 @@ import 'package:privacy_gui/page/_shared/models/time_settings_ui_model.dart'; import 'package:privacy_gui/page/_shared/models/traffic_analysis_state.dart'; import 'package:privacy_gui/page/_shared/models/wan_status_ui_model.dart'; import 'package:privacy_gui/page/_shared/models/wifi_client_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/wifi_connection_info.dart'; import 'package:privacy_gui/page/_shared/models/wifi_radio_ui_model.dart'; import 'package:privacy_gui/page/admin/providers/system_info_data_provider.dart'; import 'package:privacy_gui/page/admin/providers/time_data_provider.dart'; @@ -28,7 +30,6 @@ import 'package:privacy_gui/page/local_network/providers/lan_data_provider.dart' import 'package:privacy_gui/page/port_forwarding/models/port_triggering_rule_ui_model.dart'; import 'package:privacy_gui/page/port_forwarding/providers/port_forwarding_data_provider.dart'; import 'package:privacy_gui/page/port_forwarding/providers/port_triggering_data_provider.dart'; -import 'package:privacy_gui/page/topology/models/node_ui_model.dart'; import 'package:privacy_gui/page/wifi_settings/providers/wifi_data_provider.dart'; // --------------------------------------------------------------------------- @@ -98,8 +99,22 @@ const testWanOffline = WanStatusUIModel( mtu: 1500, ); +// WAN whose upstream assigns no global/ULA prefix — only a link-local +// (fe80::/10) address. The card still shows it, tagged with a scope badge. +const testWanLinkLocalOnly = WanStatusUIModel( + isUp: true, + ipAddress: '10.92.12.87', + subnetMask: '255.255.255.0', + addressingType: 'DHCP', + mtu: 1500, + gateway: '10.92.12.1', + ipv6Enabled: true, + ipv6Addresses: ['fe80::7612:13ff:fe21:5502'], +); + final testWanOnlineData = WanData(model: testWanOnline); final testWanOfflineData = WanData(model: testWanOffline); +final testWanLinkLocalOnlyData = WanData(model: testWanLinkLocalOnly); // --------------------------------------------------------------------------- // LAN Info @@ -117,7 +132,22 @@ const testLanDhcpEnabled = LanInfoUIModel( ipv6Addresses: ['fd00::1'], ); +// LAN whose bridge holds only a link-local (fe80::/10) address — no global/ULA +// prefix. The card still shows it, tagged with a scope badge. +const testLanLinkLocalOnly = LanInfoUIModel( + ipAddress: '192.168.1.1', + subnetMask: '255.255.255.0', + dhcpEnabled: true, + minAddress: '192.168.1.100', + maxAddress: '192.168.1.200', + leaseTimeMinutes: 1440, + dnsServers: '8.8.8.8, 8.8.4.4', + ipv6Enabled: true, + ipv6Addresses: ['fe80::7612:13ff:fe21:5502'], +); + final testLanData = LanData(model: testLanDhcpEnabled); +final testLanLinkLocalOnlyData = LanData(model: testLanLinkLocalOnly); const testLanDhcpDisabled = LanInfoUIModel( ipAddress: '192.168.1.1', @@ -206,95 +236,103 @@ final testEthernetData = EthernetData(ethernetPortModels: testEthernetPorts); // Connected Devices // --------------------------------------------------------------------------- -const testDevices = [ - DeviceUIModel( +final testDevices = [ + ClientDevice( mac: 'AA:BB:CC:DD:EE:01', ip: '192.168.1.101', hostName: 'Desktop-PC', isActive: true, - isWifi: false, - layer1Interface: 'Device.Ethernet.Interface.2.', + connectionType: ConnectionType.wired, ), - DeviceUIModel( + ClientDevice( mac: 'AA:BB:CC:DD:EE:02', ip: '192.168.1.102', hostName: 'iPhone-15', isActive: true, - isWifi: true, - signalStrength: -45, - band: '5GHz', - ssidName: 'HomeNetwork', + connectionType: ConnectionType.wifi, + wifi: WifiConnectionInfo( + signalStrength: -45, + band: '5GHz', + ssidName: 'HomeNetwork', + ), parentNodeName: 'MR7500', ), - DeviceUIModel( + ClientDevice( mac: 'AA:BB:CC:DD:EE:03', ip: '192.168.1.103', hostName: 'MacBook-Air', isActive: true, - isWifi: true, - signalStrength: -55, - band: '5GHz', - ssidName: 'HomeNetwork', + connectionType: ConnectionType.wifi, + wifi: WifiConnectionInfo( + signalStrength: -55, + band: '5GHz', + ssidName: 'HomeNetwork', + ), parentNodeName: 'MR7500', ), - DeviceUIModel( + ClientDevice( mac: 'AA:BB:CC:DD:EE:04', ip: '192.168.1.104', hostName: 'Smart-Speaker', isActive: true, - isWifi: true, - signalStrength: -65, - band: '2.4GHz', - ssidName: 'HomeNetwork', + connectionType: ConnectionType.wifi, + wifi: WifiConnectionInfo( + signalStrength: -65, + band: '2.4GHz', + ssidName: 'HomeNetwork', + ), ), - DeviceUIModel( + ClientDevice( mac: 'AA:BB:CC:DD:EE:05', ip: '192.168.1.105', hostName: 'Gaming-Console', isActive: true, - isWifi: false, - layer1Interface: 'Device.Ethernet.Interface.3.', + connectionType: ConnectionType.wired, ), - DeviceUIModel( + ClientDevice( mac: 'AA:BB:CC:DD:EE:06', ip: '192.168.1.106', hostName: 'Old-Tablet', isActive: false, - isWifi: true, - signalStrength: -80, - band: '2.4GHz', + connectionType: ConnectionType.wifi, + wifi: WifiConnectionInfo( + signalStrength: -80, + band: '2.4GHz', + ), ), ]; -const testNodes = [ - NodeUIModel( +final testMeshNetwork = MeshNetwork( + master: MasterNode( deviceId: 'GATEWAY', model: 'MR7500', manufacturer: 'Linksys', serialNumber: 'ABC123456789', softwareVersion: '1.0.16', - isMaster: true, - connectedDeviceCount: 5, + connectedClients: testDevices, ), -]; - -final testDevicesData = DevicesData( - deviceModels: testDevices, - nodeModels: testNodes, - meshTopology: MeshTopologyInfo.empty, ); +final testDevicesData = DevicesData(meshNetwork: testMeshNetwork); + final testDevicesEmptyData = DevicesData( - deviceModels: const [], - nodeModels: testNodes, - meshTopology: MeshTopologyInfo.empty, + meshNetwork: MeshNetwork( + master: MasterNode( + deviceId: 'GATEWAY', + model: 'MR7500', + manufacturer: 'Linksys', + serialNumber: 'ABC123456789', + softwareVersion: '1.0.16', + connectedClients: [], + ), + ), ); // --------------------------------------------------------------------------- // DHCP Reservations & Clients // --------------------------------------------------------------------------- -const testDhcpReservations = [ +final testDhcpReservations = [ DhcpReservationUIModel( instancePath: 'Device.DHCPv4.Server.Pool.1.StaticAddress.1.', mac: 'AA:BB:CC:DD:EE:01', @@ -319,21 +357,24 @@ final testDhcpClients = [ DhcpClientUIModel( mac: 'AA:BB:CC:DD:EE:02', ip: '192.168.1.102', - active: true, + leaseActive: true, + isOnline: true, hostName: 'iPhone-15', leaseExpiry: DateTime(2024, 6, 16, 14, 30), ), DhcpClientUIModel( mac: 'AA:BB:CC:DD:EE:03', ip: '192.168.1.103', - active: true, + leaseActive: true, + isOnline: true, hostName: 'MacBook-Air', leaseExpiry: DateTime(2024, 6, 16, 10, 00), ), DhcpClientUIModel( mac: 'AA:BB:CC:DD:EE:04', ip: '192.168.1.104', - active: true, + leaseActive: true, + isOnline: true, hostName: 'Smart-Speaker', leaseExpiry: DateTime(2024, 6, 16, 8, 00), ), @@ -624,6 +665,7 @@ final testSystemMonitorWithHistory = SystemMonitorState( memoryPercent: 55 + (i * 2) % 20, totalMemoryKb: 524288, freeMemoryKb: 234288 - i * 5000, + uptimeSeconds: 86400 + i * 10, ), ), refreshInterval: Duration(seconds: 10), diff --git a/test/golden_test/page/dashboard/cards/localizations/usp_info_cards_test.dart b/test/golden_test/page/dashboard/cards/localizations/usp_info_cards_test.dart index 5abb229e4..492b2aedb 100644 --- a/test/golden_test/page/dashboard/cards/localizations/usp_info_cards_test.dart +++ b/test/golden_test/page/dashboard/cards/localizations/usp_info_cards_test.dart @@ -61,6 +61,10 @@ void main() { 'online_dhcp': (overrides) => overrides.addAll( cardOverrides(wanData: testWanOnlineData), ), + // WAN with only a link-local IPv6 — address shown with a scope badge. + 'link_local_ipv6': (overrides) => overrides.addAll( + cardOverrides(wanData: testWanLinkLocalOnlyData), + ), 'offline': (overrides) => overrides.addAll( cardOverrides(wanData: testWanOfflineData), ), @@ -81,6 +85,10 @@ void main() { 'dhcp_enabled': (overrides) => overrides.addAll( cardOverrides(lanData: testLanData), ), + // LAN with only a link-local IPv6 — address shown with a scope badge. + 'link_local_ipv6': (overrides) => overrides.addAll( + cardOverrides(lanData: testLanLinkLocalOnlyData), + ), 'dhcp_disabled': (overrides) => overrides.addAll( cardOverrides(lanData: testLanDisabledData), ), diff --git a/test/golden_test/page/devices/fixtures/devices_test_data.dart b/test/golden_test/page/devices/fixtures/devices_test_data.dart index 3a88d7f6a..b6399af3a 100644 --- a/test/golden_test/page/devices/fixtures/devices_test_data.dart +++ b/test/golden_test/page/devices/fixtures/devices_test_data.dart @@ -1,93 +1,102 @@ -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; import 'package:privacy_gui/page/_shared/models/dhcp_reservation_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/wifi_connection_info.dart'; import 'package:privacy_gui/page/devices/providers/device_detail_provider.dart'; -const wifiDevice1 = DeviceUIModel( +final wifiDevice1 = ClientDevice( mac: 'AA:BB:CC:DD:EE:01', ip: '192.168.1.100', hostName: 'iPhone-15-Pro', isActive: true, - isWifi: true, - signalStrength: -42, - downlinkRate: 866000000, - uplinkRate: 433000000, - band: '5GHz', - ssidName: 'MyNetwork', + connectionType: ConnectionType.wifi, + wifi: WifiConnectionInfo( + signalStrength: -42, + downlinkRate: 866000, + uplinkRate: 433000, + band: '5GHz', + ssidName: 'MyNetwork', + ), parentNodeId: 'node-1', parentNodeName: 'Living Room', ); -const wifiDeviceGood = DeviceUIModel( +final wifiDeviceGood = ClientDevice( mac: 'AA:BB:CC:DD:EE:02', ip: '192.168.1.101', hostName: 'MacBook-Air', isActive: true, - isWifi: true, - signalStrength: -68, - downlinkRate: 400000000, - uplinkRate: 200000000, - band: '5GHz', - ssidName: 'MyNetwork', + connectionType: ConnectionType.wifi, + wifi: WifiConnectionInfo( + signalStrength: -68, + downlinkRate: 400000, + uplinkRate: 200000, + band: '5GHz', + ssidName: 'MyNetwork', + ), parentNodeId: 'node-1', parentNodeName: 'Living Room', ); -const wiredDevice1 = DeviceUIModel( +final wiredDevice1 = ClientDevice( mac: 'AA:BB:CC:DD:EE:03', ip: '192.168.1.102', hostName: 'PlayStation-5', isActive: true, - isWifi: false, + connectionType: ConnectionType.wired, parentNodeId: 'node-1', parentNodeName: 'Living Room', ); -const offlineDevice = DeviceUIModel( +final offlineDevice = ClientDevice( mac: 'AA:BB:CC:DD:EE:04', ip: '192.168.1.103', hostName: 'iPad-Mini', isActive: false, - isWifi: true, + connectionType: ConnectionType.wifi, ); -const wifiDeviceFair = DeviceUIModel( +final wifiDeviceFair = ClientDevice( mac: 'AA:BB:CC:DD:EE:05', ip: '192.168.1.104', hostName: 'Samsung-TV', isActive: true, - isWifi: true, - signalStrength: -75, - downlinkRate: 72000000, - uplinkRate: 36000000, - band: '2.4GHz', - ssidName: 'MyNetwork', + connectionType: ConnectionType.wifi, + wifi: WifiConnectionInfo( + signalStrength: -75, + downlinkRate: 72000, + uplinkRate: 36000, + band: '2.4GHz', + ssidName: 'MyNetwork', + ), parentNodeId: 'node-2', parentNodeName: 'Bedroom', ); -const wifiDevicePoor = DeviceUIModel( +final wifiDevicePoor = ClientDevice( mac: 'AA:BB:CC:DD:EE:06', ip: '192.168.1.105', hostName: 'Nest-Cam-Outdoor', isActive: true, - isWifi: true, - signalStrength: -82, - downlinkRate: 24000000, - uplinkRate: 12000000, - band: '2.4GHz', - ssidName: 'MyNetwork', + connectionType: ConnectionType.wifi, + wifi: WifiConnectionInfo( + signalStrength: -82, + downlinkRate: 24000, + uplinkRate: 12000, + band: '2.4GHz', + ssidName: 'MyNetwork', + ), parentNodeId: 'node-2', parentNodeName: 'Bedroom', ); -const testReservation = DhcpReservationUIModel( +final testReservation = DhcpReservationUIModel( instancePath: 'Device.DHCPv4.Server.Pool.1.StaticAddress.1.', mac: 'AA:BB:CC:DD:EE:01', ip: '192.168.1.100', enable: true, ); -List get allDevices => [ +List get allDevices => [ wifiDevice1, wifiDeviceGood, wiredDevice1, @@ -113,4 +122,51 @@ DeviceDetailState get offlineDetail => DeviceDetailState( device: offlineDevice, ); +// Device with a global (routable) IPv6 address — shown without a scope badge. +final wifiDeviceGlobalIpv6 = ClientDevice( + mac: 'AA:BB:CC:DD:EE:06', + ip: '192.168.1.106', + hostName: 'Desktop-PC', + isActive: true, + connectionType: ConnectionType.wifi, + wifi: WifiConnectionInfo( + signalStrength: -42, + downlinkRate: 866000, + uplinkRate: 433000, + band: '5GHz', + ssidName: 'MyNetwork', + ), + ipv6Addresses: const ['2401:e180:8801:d79d::5'], + parentNodeId: 'node-1', + parentNodeName: 'Living Room', +); + +// Device whose only IPv6 address is link-local (fe80::/10) — shown with a +// scope badge in place of the leading icon. +final wifiDeviceLinkLocalIpv6 = ClientDevice( + mac: 'AA:BB:CC:DD:EE:07', + ip: '192.168.1.107', + hostName: 'Laptop', + isActive: true, + connectionType: ConnectionType.wifi, + wifi: WifiConnectionInfo( + signalStrength: -42, + downlinkRate: 866000, + uplinkRate: 433000, + band: '5GHz', + ssidName: 'MyNetwork', + ), + ipv6Addresses: const ['fe80::cd3:70da:d0a0:49cf'], + parentNodeId: 'node-1', + parentNodeName: 'Living Room', +); + DeviceDetailState get deviceNotFound => DeviceDetailState.empty(); + +DeviceDetailState get wifiDetailGlobalIpv6 => DeviceDetailState( + device: wifiDeviceGlobalIpv6, + ); + +DeviceDetailState get wifiDetailLinkLocalIpv6 => DeviceDetailState( + device: wifiDeviceLinkLocalIpv6, + ); diff --git a/test/golden_test/page/devices/localizations/usp_device_detail_view_test.dart b/test/golden_test/page/devices/localizations/usp_device_detail_view_test.dart index 6315ae39c..ad0a3d33d 100644 --- a/test/golden_test/page/devices/localizations/usp_device_detail_view_test.dart +++ b/test/golden_test/page/devices/localizations/usp_device_detail_view_test.dart @@ -25,6 +25,14 @@ void main() { 'wired_device': (overrides) => overrides.addAll( deviceDetailOverrides(detail: wiredDetail), ), + // IPv6 global address — rendered without a scope badge. + 'global_ipv6': (overrides) => overrides.addAll( + deviceDetailOverrides(detail: wifiDetailGlobalIpv6), + ), + // IPv6 link-local only — leading icon swapped for a scope badge. + 'link_local_ipv6': (overrides) => overrides.addAll( + deviceDetailOverrides(detail: wifiDetailLinkLocalIpv6), + ), 'offline_device': (overrides) => overrides.addAll( deviceDetailOverrides(detail: offlineDetail), ), diff --git a/test/golden_test/page/dhcp/fixtures/dhcp_test_data.dart b/test/golden_test/page/dhcp/fixtures/dhcp_test_data.dart index 4a3e8bccc..4ec1ae36b 100644 --- a/test/golden_test/page/dhcp/fixtures/dhcp_test_data.dart +++ b/test/golden_test/page/dhcp/fixtures/dhcp_test_data.dart @@ -21,34 +21,38 @@ final testClients = [ DhcpClientUIModel( mac: 'AA:BB:CC:DD:EE:01', ip: '192.168.1.100', - active: true, + leaseActive: true, + isOnline: true, hostName: 'iPhone-15-Pro', leaseExpiry: DateTime.now().add(const Duration(hours: 12)), ), DhcpClientUIModel( mac: 'AA:BB:CC:DD:EE:02', ip: '192.168.1.101', - active: true, + leaseActive: true, + isOnline: true, hostName: 'MacBook-Air', leaseExpiry: DateTime.now().add(const Duration(hours: 6, minutes: 30)), ), DhcpClientUIModel( mac: 'AA:BB:CC:DD:EE:03', ip: '192.168.1.102', - active: true, + leaseActive: true, + isOnline: true, hostName: 'PlayStation-5', leaseExpiry: DateTime.now().add(const Duration(hours: 23, minutes: 45)), ), DhcpClientUIModel( mac: 'AA:BB:CC:DD:EE:04', ip: '192.168.1.103', - active: false, + leaseActive: false, + isOnline: false, hostName: 'iPad-Mini', leaseExpiry: DateTime.now().subtract(const Duration(hours: 2)), ), ]; -const testReservations = [ +final testReservations = [ DhcpReservationUIModel( instancePath: 'Device.DHCPv4.Server.Pool.1.StaticAddress.1.', mac: 'AA:BB:CC:DD:EE:01', @@ -70,9 +74,10 @@ const testReservations = [ ]; DhcpReservationsFeatureState dataState({ - List reservations = testReservations, + List? reservations, }) { - final settings = DhcpReservationList(reservations: reservations); + final res = reservations ?? testReservations; + final settings = DhcpReservationList(reservations: res); return DhcpReservationsFeatureState( settings: Preservable(original: settings, current: settings), status: const DhcpReservationsStatus(), @@ -80,11 +85,11 @@ DhcpReservationsFeatureState dataState({ } DhcpReservationsFeatureState dirtyState() { - final original = const DhcpReservationList(reservations: testReservations); + final original = DhcpReservationList(reservations: testReservations); final current = DhcpReservationList( reservations: [ ...testReservations, - const DhcpReservationUIModel( + DhcpReservationUIModel( mac: 'FF:EE:DD:CC:BB:AA', ip: '192.168.1.160', enable: true, diff --git a/test/golden_test/page/internet_settings/fixtures/internet_settings_test_data.dart b/test/golden_test/page/internet_settings/fixtures/internet_settings_test_data.dart index d589adb11..f2f1a9b13 100644 --- a/test/golden_test/page/internet_settings/fixtures/internet_settings_test_data.dart +++ b/test/golden_test/page/internet_settings/fixtures/internet_settings_test_data.dart @@ -88,6 +88,14 @@ const defaultReadOnlyInfo = InternetSettingsReadOnlyInfo( staticIpAddress: '192.168.1.100', ); +const bridgeReadOnlyInfo = InternetSettingsReadOnlyInfo( + currentMacAddress: '11:22:33:44:55:66', + pppConnectionStatus: '', + dhcpv6Duid: '', + staticIpAddress: '', + hostName: 'Community00080', +); + const pppoeReadOnlyInfo = InternetSettingsReadOnlyInfo( currentMacAddress: '11:22:33:44:55:66', pppConnectionStatus: 'Connected', diff --git a/test/golden_test/page/internet_settings/localizations/usp_internet_settings_view_test.dart b/test/golden_test/page/internet_settings/localizations/usp_internet_settings_view_test.dart index fb5470712..947903227 100644 --- a/test/golden_test/page/internet_settings/localizations/usp_internet_settings_view_test.dart +++ b/test/golden_test/page/internet_settings/localizations/usp_internet_settings_view_test.dart @@ -35,6 +35,15 @@ void main() { 'bridge': (overrides) => overrides.addAll( internetSettingsOverrides(dataState(bridgeForm)), ), + 'bridge_editing': (overrides) => overrides.addAll( + internetSettingsOverrides( + dataState( + bridgeForm, + readOnlyInfo: bridgeReadOnlyInfo, + isEditing: true, + ), + ), + ), 'ipv6_enabled': (overrides) => overrides.addAll( internetSettingsOverrides( dataState(ipv6EnabledForm, readOnlyInfo: ipv6ReadOnlyInfo), diff --git a/test/golden_test/page/statistics/fixtures/statistics_test_data.dart b/test/golden_test/page/statistics/fixtures/statistics_test_data.dart index 3c1b457a5..3300e7e11 100644 --- a/test/golden_test/page/statistics/fixtures/statistics_test_data.dart +++ b/test/golden_test/page/statistics/fixtures/statistics_test_data.dart @@ -85,6 +85,7 @@ SystemMonitorState get testSystemMonitorState => SystemMonitorState( memoryPercent: 60 + (i * 2) % 20, totalMemoryKb: 524288, freeMemoryKb: 209715 - i * 5000, + uptimeSeconds: 86400 + i * 30, ), ), refreshInterval: const Duration(seconds: 30), diff --git a/test/golden_test/page/topology/fixtures/topology_test_data.dart b/test/golden_test/page/topology/fixtures/topology_test_data.dart index cc2a8ee3d..bf54655f9 100644 --- a/test/golden_test/page/topology/fixtures/topology_test_data.dart +++ b/test/golden_test/page/topology/fixtures/topology_test_data.dart @@ -1,9 +1,11 @@ -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; -import 'package:privacy_gui/page/_shared/models/mesh_topology_info.dart'; +import 'package:privacy_gui/page/_shared/models/backhaul_info.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_network.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; import 'package:privacy_gui/page/_shared/models/system_info_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/wifi_connection_info.dart'; import 'package:privacy_gui/page/admin/providers/system_info_data_provider.dart'; import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; -import 'package:privacy_gui/page/topology/models/node_ui_model.dart'; import 'package:privacy_gui/page/topology/providers/node_detail_provider.dart'; // --------------------------------------------------------------------------- @@ -28,64 +30,60 @@ final testSystemInfoData = SystemInfoData(model: _testSystemInfo); // Devices // --------------------------------------------------------------------------- -const _testDevices = [ - DeviceUIModel( +final _testClients = [ + ClientDevice( mac: 'AA:BB:CC:DD:EE:01', ip: '192.168.1.100', hostName: 'iPhone', isActive: true, - isWifi: true, - signalStrength: -45, - band: '5GHz', - parentNodeId: null, + connectionType: ConnectionType.wifi, + wifi: WifiConnectionInfo(signalStrength: -45, band: '5GHz'), ), - DeviceUIModel( + ClientDevice( mac: 'AA:BB:CC:DD:EE:02', ip: '192.168.1.101', hostName: 'MacBook Pro', isActive: true, - isWifi: true, - signalStrength: -55, - band: '5GHz', - parentNodeId: null, + connectionType: ConnectionType.wifi, + wifi: WifiConnectionInfo(signalStrength: -55, band: '5GHz'), ), - DeviceUIModel( + ClientDevice( mac: 'AA:BB:CC:DD:EE:03', ip: '192.168.1.102', hostName: 'Desktop PC', isActive: true, - isWifi: false, - parentNodeId: null, + connectionType: ConnectionType.wired, ), ]; -const _meshDevices = [ - DeviceUIModel( +final _meshMasterClients = [ + ClientDevice( mac: 'AA:BB:CC:DD:EE:01', ip: '192.168.1.100', hostName: 'iPhone', isActive: true, - isWifi: true, - signalStrength: -45, - band: '5GHz', + connectionType: ConnectionType.wifi, + wifi: WifiConnectionInfo(signalStrength: -45, band: '5GHz'), parentNodeId: '11:22:33:44:55:66', ), - DeviceUIModel( + ClientDevice( mac: 'AA:BB:CC:DD:EE:02', ip: '192.168.1.101', hostName: 'MacBook Pro', isActive: true, - isWifi: true, - signalStrength: -55, - band: '5GHz', + connectionType: ConnectionType.wifi, + wifi: WifiConnectionInfo(signalStrength: -55, band: '5GHz'), parentNodeId: '11:22:33:44:55:66', ), - DeviceUIModel( +]; + +final _meshSlaveClients = [ + ClientDevice( mac: 'AA:BB:CC:DD:EE:03', ip: '192.168.1.102', hostName: 'Desktop PC', isActive: true, - isWifi: false, + connectionType: ConnectionType.wired, parentNodeId: 'AA:BB:CC:DD:FF:01', ), ]; @@ -95,71 +93,39 @@ const _meshDevices = [ // --------------------------------------------------------------------------- final singleNodeDevicesData = DevicesData( - deviceModels: _testDevices, - nodeModels: const [ - NodeUIModel( + meshNetwork: MeshNetwork( + master: MasterNode( deviceId: 'gateway', model: 'MR7500', manufacturer: 'Linksys', serialNumber: 'ABC123456', softwareVersion: '1.0.16.215118', - isMaster: true, - connectedDeviceCount: 3, + connectedClients: _testClients, ), - ], - meshTopology: MeshTopologyInfo.empty, + ), ); final meshNetworkDevicesData = DevicesData( - deviceModels: _meshDevices, - nodeModels: const [ - NodeUIModel( + meshNetwork: MeshNetwork( + master: MasterNode( deviceId: '11:22:33:44:55:66', model: 'MR7500', manufacturer: 'Linksys', serialNumber: 'ABC123456', softwareVersion: '1.0.16.215118', - isMaster: true, - connectedDeviceCount: 2, + connectedClients: _meshMasterClients, ), - NodeUIModel( - deviceId: 'AA:BB:CC:DD:FF:01', - model: 'MX2000', - manufacturer: 'Linksys', - serialNumber: 'DEF789012', - softwareVersion: '1.0.10.200000', - isMaster: false, - connectedDeviceCount: 1, - ), - ], - meshTopology: const MeshTopologyInfo( - nodes: [ - NodeUIModel( - deviceId: '11:22:33:44:55:66', - model: 'MR7500', - manufacturer: 'Linksys', - serialNumber: 'ABC123456', - softwareVersion: '1.0.16.215118', - isMaster: true, - connectedDeviceCount: 2, - instancePath: 'Device.DeviceInfo.1.', - ), - NodeUIModel( + slaves: [ + SlaveNode( deviceId: 'AA:BB:CC:DD:FF:01', model: 'MX2000', manufacturer: 'Linksys', serialNumber: 'DEF789012', softwareVersion: '1.0.10.200000', - isMaster: false, - connectedDeviceCount: 1, - instancePath: 'Device.DeviceInfo.2.', + connectedClients: _meshSlaveClients, + backhaul: BackhaulInfo(mediaType: 'Wi-Fi', signalStrength: -50), ), ], - clientToNodeMap: { - 'AA:BB:CC:DD:EE:01': '11:22:33:44:55:66', - 'AA:BB:CC:DD:EE:02': '11:22:33:44:55:66', - 'AA:BB:CC:DD:EE:03': 'AA:BB:CC:DD:FF:01', - }, ), ); @@ -167,73 +133,72 @@ final meshNetworkDevicesData = DevicesData( // Node Detail States // --------------------------------------------------------------------------- -const masterNodeWithDevices = UspNodeDetailState( - node: NodeUIModel( +final masterNodeWithDevices = UspNodeDetailState( + node: MasterNode( deviceId: '11:22:33:44:55:66', model: 'MR7500', manufacturer: 'Linksys', serialNumber: 'ABC123456', softwareVersion: '1.0.16.215118', - isMaster: true, - connectedDeviceCount: 2, + connectedClients: _meshMasterClients, ), - connectedDevices: [ - DeviceUIModel( - mac: 'AA:BB:CC:DD:EE:01', - ip: '192.168.1.100', - hostName: 'iPhone', - isActive: true, - isWifi: true, - signalStrength: -45, - band: '5GHz', - ), - DeviceUIModel( - mac: 'AA:BB:CC:DD:EE:02', - ip: '192.168.1.101', - hostName: 'MacBook Pro', - isActive: true, - isWifi: true, - signalStrength: -55, - band: '5GHz', - ), - ], + connectedClients: _meshMasterClients, ); -const slaveNodeWithDevices = UspNodeDetailState( - node: NodeUIModel( +final slaveNodeWithDevices = UspNodeDetailState( + node: SlaveNode( deviceId: 'AA:BB:CC:DD:FF:01', model: 'MX2000', manufacturer: 'Linksys', serialNumber: 'DEF789012', softwareVersion: '1.0.10.200000', - isMaster: false, - connectedDeviceCount: 1, + connectedClients: _meshSlaveClients, + backhaul: BackhaulInfo(mediaType: 'Wi-Fi', signalStrength: -50), ), - connectedDevices: [ - DeviceUIModel( - mac: 'AA:BB:CC:DD:EE:03', - ip: '192.168.1.102', - hostName: 'Desktop PC', - isActive: true, - isWifi: false, - ), - ], + connectedClients: _meshSlaveClients, ); -const masterNodeEmptyDevices = UspNodeDetailState( - node: NodeUIModel( +final masterNodeEmptyDevices = UspNodeDetailState( + node: MasterNode( deviceId: '11:22:33:44:55:66', model: 'MR7500', manufacturer: 'Linksys', serialNumber: 'ABC123456', softwareVersion: '1.0.16.215118', - isMaster: true, - connectedDeviceCount: 0, + connectedClients: [], ), - connectedDevices: [], + connectedClients: [], ); -const nodeNotFoundState = UspNodeDetailState( - node: null, - connectedDevices: [], +// Slave node with a global (routable) LAN IPv6 — shown without a scope badge. +final slaveNodeGlobalIpv6 = UspNodeDetailState( + node: SlaveNode( + deviceId: 'AA:BB:CC:DD:FF:02', + model: 'MX2000', + manufacturer: 'Linksys', + serialNumber: 'DEF789013', + softwareVersion: '1.0.10.200000', + ipv6Addresses: const ['2401:e180:8801:d79d::5'], + connectedClients: _meshSlaveClients, + backhaul: BackhaulInfo(mediaType: 'Wi-Fi', signalStrength: -50), + ), + connectedClients: _meshSlaveClients, +); + +// Slave node whose only LAN IPv6 is link-local (fe80::/10) — shown with a +// scope badge in place of the leading icon. +final slaveNodeLinkLocalIpv6 = UspNodeDetailState( + node: SlaveNode( + deviceId: 'AA:BB:CC:DD:FF:03', + model: 'MX2000', + manufacturer: 'Linksys', + serialNumber: 'DEF789014', + softwareVersion: '1.0.10.200000', + ipv6Addresses: const ['fe80::7612:13ff:fe21:5503'], + connectedClients: _meshSlaveClients, + backhaul: BackhaulInfo(mediaType: 'Wi-Fi', signalStrength: -50), + ), + connectedClients: _meshSlaveClients, ); + +const nodeNotFoundState = UspNodeDetailState(); diff --git a/test/golden_test/page/topology/localizations/usp_node_detail_view_test.dart b/test/golden_test/page/topology/localizations/usp_node_detail_view_test.dart index a3d221f35..6e2696b73 100644 --- a/test/golden_test/page/topology/localizations/usp_node_detail_view_test.dart +++ b/test/golden_test/page/topology/localizations/usp_node_detail_view_test.dart @@ -18,6 +18,14 @@ void main() { 'slave_with_devices': (overrides) => overrides.addAll( nodeDetailOverrides(slaveNodeWithDevices), ), + // LAN IPv6 global address — rendered without a scope badge. + 'global_ipv6': (overrides) => overrides.addAll( + nodeDetailOverrides(slaveNodeGlobalIpv6), + ), + // LAN IPv6 link-local only — leading icon swapped for a scope badge. + 'link_local_ipv6': (overrides) => overrides.addAll( + nodeDetailOverrides(slaveNodeLinkLocalIpv6), + ), 'empty_devices': (overrides) => overrides.addAll( nodeDetailOverrides(masterNodeEmptyDevices), ), diff --git a/test/mocks/test_data/devices_test_data.dart b/test/mocks/test_data/devices_test_data.dart new file mode 100644 index 000000000..d997c829c --- /dev/null +++ b/test/mocks/test_data/devices_test_data.dart @@ -0,0 +1,486 @@ +import 'package:privacy_gui/page/_shared/models/backhaul_info.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_network.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_topology_info.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; +import 'package:privacy_gui/page/_shared/models/wifi_connection_info.dart'; +import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; + +/// Test data builder for device/mesh network tests. +/// +/// Provides factory methods to create test instances with sensible defaults. +/// Centralizes test data creation to avoid inline construction duplication. +class DevicesTestData { + // =========================================================================== + // Constants + // =========================================================================== + + static const masterMac = 'AA:BB:CC:DD:EE:00'; + static const slaveMac1 = 'AA:BB:CC:DD:EE:01'; + static const slaveMac2 = 'AA:BB:CC:DD:EE:02'; + + static const clientMac1 = '11:22:33:44:55:01'; + static const clientMac2 = '11:22:33:44:55:02'; + static const clientMac3 = '11:22:33:44:55:03'; + static const clientMac4 = '11:22:33:44:55:04'; + static const clientMac5 = '11:22:33:44:55:05'; + + static const masterIp = '192.168.1.1'; + static const clientIp1 = '192.168.1.101'; + static const clientIp2 = '192.168.1.102'; + static const clientIp3 = '192.168.1.103'; + + static const defaultModel = 'MR7500'; + static const defaultManufacturer = 'Linksys'; + static const defaultSerialNumber = 'SN123456789'; + static const defaultSoftwareVersion = '1.0.16.26013014'; + + // =========================================================================== + // WifiConnectionInfo Factories + // =========================================================================== + + static WifiConnectionInfo createWifiInfo({ + int? signalStrength = -50, + String? band = '5GHz', + String? ssidName = 'HomeNetwork', + int? downlinkRate = 866000, + int? uplinkRate = 433000, + }) => + WifiConnectionInfo( + signalStrength: signalStrength, + band: band, + ssidName: ssidName, + downlinkRate: downlinkRate, + uplinkRate: uplinkRate, + ); + + static WifiConnectionInfo createExcellentSignal() => createWifiInfo( + signalStrength: -45, + band: '5GHz', + ); + + static WifiConnectionInfo createGoodSignal() => createWifiInfo( + signalStrength: -68, + band: '5GHz', + ); + + static WifiConnectionInfo createFairSignal() => createWifiInfo( + signalStrength: -75, + band: '2.4GHz', + ); + + static WifiConnectionInfo createPoorSignal() => createWifiInfo( + signalStrength: -85, + band: '2.4GHz', + ); + + // =========================================================================== + // BackhaulInfo Factories + // =========================================================================== + + static BackhaulInfo createWifiBackhaul({ + int signalStrength = -55, + String parentNodeId = masterMac, + }) => + BackhaulInfo( + mediaType: 'IEEE 802.11ax', + linkType: 'Wi-Fi', + phyRate: 2402, + signalStrength: signalStrength, + uplinkRate: 500000, + downlinkRate: 1000000, + parentNodeId: parentNodeId, + ); + + static BackhaulInfo createEthernetBackhaul({ + String parentNodeId = masterMac, + }) => + BackhaulInfo( + mediaType: 'Ethernet', + linkType: 'Ethernet', + phyRate: 1000, + parentNodeId: parentNodeId, + ); + + static const emptyBackhaul = BackhaulInfo(mediaType: ''); + + // =========================================================================== + // ClientInterfaceInfo Factories + // =========================================================================== + + static ClientInterfaceInfo createWifiInterface({ + String mac = '11:22:33:44:55:FF', + String ip = '192.168.1.150', + bool isActive = true, + WifiConnectionInfo? wifi, + }) => + ClientInterfaceInfo( + mac: mac, + ip: ip, + connectionType: ConnectionType.wifi, + isActive: isActive, + wifi: wifi ?? createWifiInfo(), + ); + + static ClientInterfaceInfo createWiredInterface({ + String mac = '11:22:33:44:55:FE', + String ip = '192.168.1.151', + bool isActive = true, + }) => + ClientInterfaceInfo( + mac: mac, + ip: ip, + connectionType: ConnectionType.wired, + isActive: isActive, + ); + + // =========================================================================== + // ClientDevice Factories + // =========================================================================== + + /// Creates an online WiFi client device. + static ClientDevice createWifiClient({ + String mac = clientMac1, + String ip = clientIp1, + String hostName = 'MacBook-Pro', + String? friendlyName, + bool isActive = true, + WifiConnectionInfo? wifi, + String? parentNodeId, + String? parentNodeName, + List additionalInterfaces = const [], + }) => + ClientDevice( + mac: mac, + ip: ip, + hostName: hostName, + friendlyName: friendlyName, + isActive: isActive, + connectionType: ConnectionType.wifi, + wifi: wifi ?? createWifiInfo(), + parentNodeId: parentNodeId, + parentNodeName: parentNodeName, + additionalInterfaces: additionalInterfaces, + ); + + /// Creates an online wired client device. + static ClientDevice createWiredClient({ + String mac = clientMac2, + String ip = clientIp2, + String hostName = 'Desktop-PC', + String? friendlyName, + bool isActive = true, + String? parentNodeId, + String? parentNodeName, + List additionalInterfaces = const [], + }) => + ClientDevice( + mac: mac, + ip: ip, + hostName: hostName, + friendlyName: friendlyName, + isActive: isActive, + connectionType: ConnectionType.wired, + parentNodeId: parentNodeId, + parentNodeName: parentNodeName, + additionalInterfaces: additionalInterfaces, + ); + + /// Creates an offline client device. + static ClientDevice createOfflineClient({ + String mac = clientMac3, + String ip = clientIp3, + String hostName = 'Offline-Device', + ConnectionType connectionType = ConnectionType.wifi, + }) => + ClientDevice( + mac: mac, + ip: ip, + hostName: hostName, + isActive: false, + connectionType: connectionType, + ); + + /// Creates a client with multiple network interfaces. + static ClientDevice createMultiInterfaceClient({ + String mac = clientMac4, + String ip = '192.168.1.104', + String hostName = 'Multi-Interface-Device', + bool isActive = true, + List? additionalInterfaces, + }) => + ClientDevice( + mac: mac, + ip: ip, + hostName: hostName, + isActive: isActive, + connectionType: ConnectionType.wifi, + wifi: createWifiInfo(), + additionalInterfaces: additionalInterfaces ?? + [ + createWiredInterface( + mac: '${mac.substring(0, 14)}:FE', + ip: '192.168.1.204', + ), + ], + ); + + /// Creates a client connected to a slave (extender) node. + static ClientDevice createSlaveConnectedClient({ + String mac = clientMac5, + String ip = '192.168.1.105', + String hostName = 'Extender-Client', + String parentNodeId = slaveMac1, + String parentNodeName = 'Extender-1', + bool isWifi = true, + bool isActive = true, + }) => + ClientDevice( + mac: mac, + ip: ip, + hostName: hostName, + isActive: isActive, + connectionType: isWifi ? ConnectionType.wifi : ConnectionType.wired, + wifi: isWifi ? createWifiInfo() : null, + parentNodeId: parentNodeId, + parentNodeName: parentNodeName, + ); + + // =========================================================================== + // NodeEntity Factories + // =========================================================================== + + /// Creates a master (gateway) node. + static MasterNode createMaster({ + String deviceId = masterMac, + String? dataElementsId, + String? friendlyName, + String? hostName = 'Linksys-Router', + String model = defaultModel, + String manufacturer = defaultManufacturer, + String serialNumber = defaultSerialNumber, + String softwareVersion = defaultSoftwareVersion, + String? ipAddress = masterIp, + List connectedClients = const [], + String? wanIpAddress = '100.64.1.100', + String? hostsDeviceId, + }) => + MasterNode( + deviceId: deviceId, + dataElementsId: dataElementsId, + friendlyName: friendlyName, + hostName: hostName, + model: model, + manufacturer: manufacturer, + serialNumber: serialNumber, + softwareVersion: softwareVersion, + ipAddress: ipAddress, + connectedClients: connectedClients, + wanIpAddress: wanIpAddress, + hostsDeviceId: hostsDeviceId, + ); + + /// Creates a slave (extender) node with WiFi backhaul. + static SlaveNode createWifiSlave({ + String deviceId = slaveMac1, + String? dataElementsId, + String? friendlyName, + String? hostName = 'Extender-1', + String model = defaultModel, + String? ipAddress = '192.168.1.2', + List connectedClients = const [], + BackhaulInfo? backhaul, + }) => + SlaveNode( + deviceId: deviceId, + dataElementsId: dataElementsId, + friendlyName: friendlyName, + hostName: hostName, + model: model, + manufacturer: defaultManufacturer, + ipAddress: ipAddress, + connectedClients: connectedClients, + backhaul: backhaul ?? createWifiBackhaul(), + ); + + /// Creates a slave (extender) node with Ethernet backhaul. + static SlaveNode createEthernetSlave({ + String deviceId = slaveMac2, + String? dataElementsId, + String? friendlyName, + String? hostName = 'Extender-2', + String model = defaultModel, + String? ipAddress = '192.168.1.3', + List connectedClients = const [], + }) => + SlaveNode( + deviceId: deviceId, + dataElementsId: dataElementsId, + friendlyName: friendlyName, + hostName: hostName, + model: model, + manufacturer: defaultManufacturer, + ipAddress: ipAddress, + connectedClients: connectedClients, + backhaul: createEthernetBackhaul(), + ); + + // =========================================================================== + // MeshNetwork Factories + // =========================================================================== + + /// Creates a simple single-node (non-mesh) network. + static MeshNetwork createSingleNodeNetwork({ + MasterNode? master, + List? masterClients, + }) { + final clients = masterClients ?? + [ + createWifiClient(), + createWiredClient(), + ]; + return MeshNetwork( + master: master?.copyWith(connectedClients: clients) ?? + createMaster(connectedClients: clients), + ); + } + + /// Creates a mesh network with one master and one WiFi slave. + static MeshNetwork createMeshNetwork({ + MasterNode? master, + List? masterClients, + SlaveNode? slave, + List? slaveClients, + }) { + final mClients = masterClients ?? [createWifiClient()]; + final sClients = slaveClients ?? + [ + createSlaveConnectedClient(), + ]; + + return MeshNetwork( + master: master?.copyWith(connectedClients: mClients) ?? + createMaster(connectedClients: mClients), + slaves: [ + slave?.copyWith(connectedClients: sClients) ?? + createWifiSlave(connectedClients: sClients), + ], + ); + } + + /// Creates a multi-slave mesh network. + static MeshNetwork createMultiSlaveMeshNetwork({ + List? masterClients, + List? slave1Clients, + List? slave2Clients, + }) { + return MeshNetwork( + master: createMaster( + connectedClients: masterClients ?? [createWifiClient()], + ), + slaves: [ + createWifiSlave( + deviceId: slaveMac1, + hostName: 'Extender-1', + connectedClients: slave1Clients ?? + [ + createSlaveConnectedClient( + mac: clientMac3, + parentNodeId: slaveMac1, + parentNodeName: 'Extender-1', + ), + ], + ), + createEthernetSlave( + deviceId: slaveMac2, + hostName: 'Extender-2', + connectedClients: slave2Clients ?? + [ + createSlaveConnectedClient( + mac: clientMac4, + parentNodeId: slaveMac2, + parentNodeName: 'Extender-2', + ), + ], + ), + ], + ); + } + + /// Creates an empty network (master only, no clients). + static MeshNetwork createEmptyNetwork() => MeshNetwork( + master: createMaster(connectedClients: []), + ); + + /// Creates a network with unassigned clients. + static MeshNetwork createNetworkWithUnassignedClients({ + List? unassignedClients, + }) => + MeshNetwork( + master: createMaster(connectedClients: []), + unassignedClients: unassignedClients ?? + [ + createWifiClient(parentNodeId: null), + createWiredClient(parentNodeId: null), + ], + ); + + // =========================================================================== + // DevicesData Factories + // =========================================================================== + + /// Creates a complete DevicesData instance. + static DevicesData createDevicesData({ + MeshNetwork? meshNetwork, + MeshTopologyInfo meshTopology = MeshTopologyInfo.empty, + Map? hostNameByMac, + }) => + DevicesData( + codegenContext: DevicesCodegenContext.empty, + meshTopology: meshTopology, + hostNameByMac: hostNameByMac ?? {}, + meshNetwork: meshNetwork ?? createSingleNodeNetwork(), + ); + + /// Creates DevicesData with a simple single-node network. + static DevicesData createSimpleDevicesData() => createDevicesData( + meshNetwork: createSingleNodeNetwork(), + ); + + /// Creates DevicesData with a mesh network. + static DevicesData createMeshDevicesData() => createDevicesData( + meshNetwork: createMeshNetwork(), + ); + + // =========================================================================== + // Client Device Lists (for filter/list tests) + // =========================================================================== + + /// Creates a mixed list of online and offline devices. + static List createMixedClientList() => [ + createWifiClient(mac: clientMac1, isActive: true), + createWiredClient(mac: clientMac2, isActive: true), + createOfflineClient(mac: clientMac3), + createWifiClient(mac: clientMac4, isActive: false), + ]; + + /// Creates a list of devices with different signal strengths. + static List createSignalVarietyList() => [ + createWifiClient( + mac: '11:22:33:44:55:E1', + wifi: createExcellentSignal(), + ), + createWifiClient( + mac: '11:22:33:44:55:E2', + wifi: createGoodSignal(), + ), + createWifiClient( + mac: '11:22:33:44:55:E3', + wifi: createFairSignal(), + ), + createWifiClient( + mac: '11:22:33:44:55:E4', + wifi: createPoorSignal(), + ), + ]; +} diff --git a/test/mocks/test_data/wifi_settings_test_data.dart b/test/mocks/test_data/wifi_settings_test_data.dart index f7ec84b48..e05d4dc2e 100644 --- a/test/mocks/test_data/wifi_settings_test_data.dart +++ b/test/mocks/test_data/wifi_settings_test_data.dart @@ -22,6 +22,7 @@ class WifiSettingsTestData { String ssid = 'TestNetwork', bool enable = true, String lowerLayers = 'Device.WiFi.Radio.1.', + String? alias, }) => WiFiSsid( instancePath: instancePath, @@ -30,6 +31,7 @@ class WifiSettingsTestData { status: enable ? 'Up' : 'Down', bssid: 'AA:BB:CC:DD:EE:FF', lowerLayers: lowerLayers, + alias: alias, ); static WiFiAccessPoint createAccessPoint({ @@ -85,22 +87,28 @@ class WifiSettingsTestData { // Codegen collections // --------------------------------------------------------------------------- - /// Typical tri-band setup: 2.4 GHz + 5 GHz + guest. + /// Typical dual-band + guest setup: 2.4 GHz + 5 GHz + guest. + /// + /// Guest is identified by the canonical `-guest` alias suffix (see + /// wifi_guest_detection), matching firmware auto-provisioned aliases. static WiFiSsids createSsids() => WiFiSsids(items: [ createSsid( instancePath: 'Device.WiFi.SSID.1.', ssid: 'Home', lowerLayers: 'Device.WiFi.Radio.1.', + alias: 'wifi-2g', ), createSsid( instancePath: 'Device.WiFi.SSID.2.', ssid: 'Home', lowerLayers: 'Device.WiFi.Radio.2.', + alias: 'wifi-5g', ), createSsid( instancePath: 'Device.WiFi.SSID.3.', ssid: 'Home-Guest', lowerLayers: 'Device.WiFi.Radio.1.', + alias: 'wifi-2g-guest', ), ]); @@ -151,34 +159,40 @@ class WifiSettingsTestData { instancePath: 'Device.WiFi.SSID.1.', ssid: 'Home', lowerLayers: 'Device.WiFi.Radio.1.', + alias: 'wifi-2g', ), createSsid( instancePath: 'Device.WiFi.SSID.2.', ssid: 'Home', lowerLayers: 'Device.WiFi.Radio.2.', + alias: 'wifi-5g', ), createSsid( instancePath: 'Device.WiFi.SSID.3.', ssid: 'Home', lowerLayers: 'Device.WiFi.Radio.3.', + alias: 'wifi-6g', ), createSsid( instancePath: 'Device.WiFi.SSID.4.', ssid: 'Home-Guest', enable: false, lowerLayers: 'Device.WiFi.Radio.1.', + alias: 'wifi-2g-guest', ), createSsid( instancePath: 'Device.WiFi.SSID.5.', ssid: 'Home-Guest', enable: false, lowerLayers: 'Device.WiFi.Radio.2.', + alias: 'wifi-5g-guest', ), createSsid( instancePath: 'Device.WiFi.SSID.6.', ssid: 'Home-Guest', enable: false, lowerLayers: 'Device.WiFi.Radio.3.', + alias: 'wifi-6g-guest', ), ]); diff --git a/test/page/_shared/models/client_device_test.dart b/test/page/_shared/models/client_device_test.dart new file mode 100644 index 000000000..233b4a57e --- /dev/null +++ b/test/page/_shared/models/client_device_test.dart @@ -0,0 +1,525 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; +import 'package:privacy_gui/page/_shared/models/wifi_connection_info.dart'; + +import '../../../mocks/test_data/devices_test_data.dart'; + +void main() { + group('ClientDevice', () { + // ========================================================================= + // NetworkEntity Implementation + // ========================================================================= + + group('NetworkEntity implementation', () { + test('id returns mac', () { + final device = DevicesTestData.createWifiClient( + mac: DevicesTestData.clientMac1, + ); + + expect(device.id, DevicesTestData.clientMac1); + }); + + test('isOnline returns isActive', () { + final online = DevicesTestData.createWifiClient(isActive: true); + final offline = DevicesTestData.createWifiClient(isActive: false); + + expect(online.isOnline, isTrue); + expect(offline.isOnline, isFalse); + }); + + test('ipAddress returns ip if not empty', () { + final withIp = DevicesTestData.createWifiClient(ip: '192.168.1.100'); + final noIp = ClientDevice( + mac: DevicesTestData.clientMac1, + hostName: 'test', + isActive: true, + ip: '', + connectionType: ConnectionType.wifi, + ); + + expect(withIp.ipAddress, '192.168.1.100'); + expect(noIp.ipAddress, isNull); + }); + }); + + // ========================================================================= + // displayName Priority + // ========================================================================= + + group('displayName', () { + test('returns friendlyName when set', () { + final device = DevicesTestData.createWifiClient( + friendlyName: 'My MacBook', + hostName: 'MacBook-Pro', + mac: DevicesTestData.clientMac1, + ); + + expect(device.displayName, 'My MacBook'); + }); + + test('returns hostName when friendlyName is null', () { + final device = DevicesTestData.createWifiClient( + friendlyName: null, + hostName: 'MacBook-Pro', + mac: DevicesTestData.clientMac1, + ); + + expect(device.displayName, 'MacBook-Pro'); + }); + + test('returns hostName when friendlyName is empty', () { + final device = ClientDevice( + mac: DevicesTestData.clientMac1, + hostName: 'MacBook-Pro', + friendlyName: '', + isActive: true, + ip: '192.168.1.100', + connectionType: ConnectionType.wifi, + ); + + expect(device.displayName, 'MacBook-Pro'); + }); + + test('returns mac when both friendlyName and hostName are empty', () { + final device = ClientDevice( + mac: DevicesTestData.clientMac1, + hostName: '', + friendlyName: '', + isActive: true, + ip: '192.168.1.100', + connectionType: ConnectionType.wifi, + ); + + expect(device.displayName, DevicesTestData.clientMac1); + }); + }); + + // ========================================================================= + // WiFi Properties + // ========================================================================= + + group('WiFi properties', () { + test('isWifi returns true for WiFi connection', () { + final wifi = DevicesTestData.createWifiClient(); + final wired = DevicesTestData.createWiredClient(); + + expect(wifi.isWifi, isTrue); + expect(wired.isWifi, isFalse); + }); + + test('signalStrength delegates to wifi', () { + final withSignal = DevicesTestData.createWifiClient( + wifi: DevicesTestData.createWifiInfo(signalStrength: -50), + ); + final noWifi = DevicesTestData.createWiredClient(); + + expect(withSignal.signalStrength, -50); + expect(noWifi.signalStrength, isNull); + }); + + test('signalQuality delegates to wifi with default 0', () { + final withWifi = DevicesTestData.createWifiClient( + wifi: DevicesTestData.createWifiInfo(signalStrength: -50), + ); + final noWifi = DevicesTestData.createWiredClient(); + + expect(withWifi.signalQuality, greaterThan(0)); + expect(noWifi.signalQuality, 0); + }); + + test('signalLevel delegates to wifi with default 0', () { + final excellent = DevicesTestData.createWifiClient( + wifi: DevicesTestData.createExcellentSignal(), + ); + final poor = DevicesTestData.createWifiClient( + wifi: DevicesTestData.createPoorSignal(), + ); + final wired = DevicesTestData.createWiredClient(); + + expect(excellent.signalLevel, 3); + expect(poor.signalLevel, 0); + expect(wired.signalLevel, 0); + }); + + test('band delegates to wifi', () { + final wifi5g = DevicesTestData.createWifiClient( + wifi: DevicesTestData.createWifiInfo(band: '5GHz'), + ); + final wifi24g = DevicesTestData.createWifiClient( + wifi: DevicesTestData.createWifiInfo(band: '2.4GHz'), + ); + final wired = DevicesTestData.createWiredClient(); + + expect(wifi5g.band, '5GHz'); + expect(wifi24g.band, '2.4GHz'); + expect(wired.band, isNull); + }); + + test('ssidName delegates to wifi', () { + final withSsid = DevicesTestData.createWifiClient( + wifi: DevicesTestData.createWifiInfo(ssidName: 'MyNetwork'), + ); + final wired = DevicesTestData.createWiredClient(); + + expect(withSsid.ssidName, 'MyNetwork'); + expect(wired.ssidName, isNull); + }); + + test('hasWifiData returns false when wifi is null', () { + final wired = DevicesTestData.createWiredClient(); + expect(wired.hasWifiData, isFalse); + }); + + test('hasWifiData returns true when wifi has data', () { + final wifi = DevicesTestData.createWifiClient(); + expect(wifi.hasWifiData, isTrue); + }); + }); + + // ========================================================================= + // Throughput + // ========================================================================= + + group('throughput', () { + test('downlinkRate delegates to wifi', () { + final wifi = DevicesTestData.createWifiClient( + wifi: DevicesTestData.createWifiInfo(downlinkRate: 866000), + ); + expect(wifi.downlinkRate, 866000); + }); + + test('uplinkRate delegates to wifi', () { + final wifi = DevicesTestData.createWifiClient( + wifi: DevicesTestData.createWifiInfo(uplinkRate: 433000), + ); + expect(wifi.uplinkRate, 433000); + }); + + test('totalThroughput sums uplink and downlink', () { + final wifi = DevicesTestData.createWifiClient( + wifi: DevicesTestData.createWifiInfo( + downlinkRate: 866000, + uplinkRate: 433000, + ), + ); + expect(wifi.totalThroughput, 866000 + 433000); + }); + + test('totalThroughput handles null rates', () { + final wifi = DevicesTestData.createWifiClient( + wifi: const WifiConnectionInfo(), + ); + expect(wifi.totalThroughput, 0); + }); + }); + + // ========================================================================= + // Multi-Interface + // ========================================================================= + + group('multi-interface', () { + test('hasMultipleInterfaces returns false when no additional interfaces', + () { + final device = DevicesTestData.createWifiClient(); + expect(device.hasMultipleInterfaces, isFalse); + }); + + test('hasMultipleInterfaces returns true when has additional interfaces', + () { + final device = DevicesTestData.createMultiInterfaceClient(); + expect(device.hasMultipleInterfaces, isTrue); + }); + + test('allMacAddresses includes primary and additional MACs', () { + final device = DevicesTestData.createMultiInterfaceClient( + mac: DevicesTestData.clientMac1, + additionalInterfaces: [ + DevicesTestData.createWiredInterface(mac: 'FF:FF:FF:FF:FF:01'), + DevicesTestData.createWifiInterface(mac: 'FF:FF:FF:FF:FF:02'), + ], + ); + + expect(device.allMacAddresses, hasLength(3)); + expect(device.allMacAddresses, contains(DevicesTestData.clientMac1)); + expect(device.allMacAddresses, contains('FF:FF:FF:FF:FF:01')); + expect(device.allMacAddresses, contains('FF:FF:FF:FF:FF:02')); + }); + + test('interfaceCount returns correct count', () { + final single = DevicesTestData.createWifiClient(); + final multi = DevicesTestData.createMultiInterfaceClient( + additionalInterfaces: [ + DevicesTestData.createWiredInterface(), + DevicesTestData.createWifiInterface(), + ], + ); + + expect(single.interfaceCount, 1); + expect(multi.interfaceCount, 3); + }); + + test('hasAnyActiveInterface checks primary and additional', () { + final allInactive = ClientDevice( + mac: DevicesTestData.clientMac1, + hostName: 'test', + isActive: false, + ip: '', + connectionType: ConnectionType.wifi, + additionalInterfaces: [ + DevicesTestData.createWiredInterface(isActive: false), + ], + ); + final oneActive = ClientDevice( + mac: DevicesTestData.clientMac1, + hostName: 'test', + isActive: false, + ip: '', + connectionType: ConnectionType.wifi, + additionalInterfaces: [ + DevicesTestData.createWiredInterface(isActive: true), + ], + ); + + expect(allInactive.hasAnyActiveInterface, isFalse); + expect(oneActive.hasAnyActiveInterface, isTrue); + }); + }); + + // ========================================================================= + // Display Logic + // ========================================================================= + + group('display logic', () { + test('hasSignalDisplay requires WiFi + online + signalStrength', () { + final valid = DevicesTestData.createWifiClient( + isActive: true, + wifi: DevicesTestData.createWifiInfo(signalStrength: -50), + ); + final offline = DevicesTestData.createWifiClient( + isActive: false, + wifi: DevicesTestData.createWifiInfo(signalStrength: -50), + ); + final noSignal = DevicesTestData.createWifiClient( + isActive: true, + wifi: const WifiConnectionInfo(), + ); + final wired = DevicesTestData.createWiredClient(isActive: true); + + expect(valid.hasSignalDisplay, isTrue); + expect(offline.hasSignalDisplay, isFalse); + expect(noSignal.hasSignalDisplay, isFalse); + expect(wired.hasSignalDisplay, isFalse); + }); + + test('shouldShowWifiDetails requires WiFi + online + (data OR signal)', + () { + final withData = DevicesTestData.createWifiClient(isActive: true); + final offline = DevicesTestData.createWifiClient(isActive: false); + final wired = DevicesTestData.createWiredClient(isActive: true); + + expect(withData.shouldShowWifiDetails, isTrue); + expect(offline.shouldShowWifiDetails, isFalse); + expect(wired.shouldShowWifiDetails, isFalse); + }); + + test('isInteractive returns isActive', () { + final active = DevicesTestData.createWifiClient(isActive: true); + final inactive = DevicesTestData.createWifiClient(isActive: false); + + expect(active.isInteractive, isTrue); + expect(inactive.isInteractive, isFalse); + }); + + test('displayOpacity returns 1.0 for online, 0.5 for offline', () { + final online = DevicesTestData.createWifiClient(isActive: true); + final offline = DevicesTestData.createWifiClient(isActive: false); + + expect(online.displayOpacity, 1.0); + expect(offline.displayOpacity, 0.5); + }); + }); + + // ========================================================================= + // List Extensions + // ========================================================================= + + group('List extensions', () { + late List devices; + + setUp(() { + devices = DevicesTestData.createMixedClientList(); + }); + + test('online filters active devices', () { + final online = devices.online; + + expect(online, hasLength(2)); + expect(online.every((d) => d.isOnline), isTrue); + }); + + test('offline filters inactive devices', () { + final offline = devices.offline; + + expect(offline, hasLength(2)); + expect(offline.every((d) => !d.isOnline), isTrue); + }); + + test('wifiDevices filters WiFi devices', () { + final wifi = devices.wifiDevices; + + expect(wifi, hasLength(3)); + expect(wifi.every((d) => d.isWifi), isTrue); + }); + + test('wiredDevices filters wired devices', () { + final wired = devices.wiredDevices; + + expect(wired, hasLength(1)); + expect(wired.every((d) => !d.isWifi), isTrue); + }); + }); + + // ========================================================================= + // Equatable + // ========================================================================= + + group('Equatable', () { + test('equal devices are equal', () { + final device1 = DevicesTestData.createWifiClient( + mac: DevicesTestData.clientMac1, + hostName: 'Test', + ); + final device2 = DevicesTestData.createWifiClient( + mac: DevicesTestData.clientMac1, + hostName: 'Test', + ); + + expect(device1, equals(device2)); + }); + + test('different mac are not equal', () { + final device1 = + DevicesTestData.createWifiClient(mac: '11:11:11:11:11:11'); + final device2 = + DevicesTestData.createWifiClient(mac: '22:22:22:22:22:22'); + + expect(device1, isNot(equals(device2))); + }); + + test('different isActive are not equal', () { + final device1 = DevicesTestData.createWifiClient(isActive: true); + final device2 = DevicesTestData.createWifiClient(isActive: false); + + expect(device1, isNot(equals(device2))); + }); + }); + + // ========================================================================= + // copyWith + // ========================================================================= + + group('copyWith', () { + test('preserves unchanged fields', () { + final original = DevicesTestData.createWifiClient(); + final copied = original.copyWith(); + + expect(copied, equals(original)); + }); + + test('updates specified fields', () { + final original = DevicesTestData.createWifiClient( + mac: DevicesTestData.clientMac1, + isActive: true, + ); + final copied = original.copyWith( + mac: 'NEW:MAC:ADDR', + isActive: false, + ); + + expect(copied.mac, 'NEW:MAC:ADDR'); + expect(copied.isActive, isFalse); + expect(copied.hostName, original.hostName); + }); + }); + }); + + // =========================================================================== + // WifiConnectionInfo + // =========================================================================== + + group('WifiConnectionInfo', () { + test('signalQuality normalizes to 0.0-1.0 range', () { + final excellent = DevicesTestData.createWifiInfo(signalStrength: -30); + final poor = DevicesTestData.createWifiInfo(signalStrength: -90); + final veryPoor = DevicesTestData.createWifiInfo(signalStrength: -100); + final noSignal = const WifiConnectionInfo(); + + expect(excellent.signalQuality, 1.0); + expect(poor.signalQuality, 0.0); + expect(veryPoor.signalQuality, 0.0); + expect(noSignal.signalQuality, 0.0); + }); + + test('signalLevel maps to 0-3 scale', () { + final excellent = DevicesTestData.createExcellentSignal(); + final good = DevicesTestData.createGoodSignal(); + final fair = DevicesTestData.createFairSignal(); + final poor = DevicesTestData.createPoorSignal(); + + expect(excellent.signalLevel, 3); + expect(good.signalLevel, 2); + expect(fair.signalLevel, 1); + expect(poor.signalLevel, 0); + }); + + test('totalThroughput sums rates', () { + final wifi = DevicesTestData.createWifiInfo( + downlinkRate: 100, + uplinkRate: 50, + ); + expect(wifi.totalThroughput, 150); + }); + + test('hasData returns true when any field is set', () { + final withSignal = DevicesTestData.createWifiInfo( + signalStrength: -50, + band: null, + ssidName: null, + downlinkRate: null, + uplinkRate: null, + ); + final empty = const WifiConnectionInfo(); + + expect(withSignal.hasData, isTrue); + expect(empty.hasData, isFalse); + }); + }); + + // =========================================================================== + // ClientInterfaceInfo + // =========================================================================== + + group('ClientInterfaceInfo', () { + test('isWifi returns true for WiFi connection type', () { + final wifi = DevicesTestData.createWifiInterface(); + final wired = DevicesTestData.createWiredInterface(); + + expect(wifi.isWifi, isTrue); + expect(wired.isWifi, isFalse); + }); + + test('signalStrength delegates to wifi', () { + final wifi = DevicesTestData.createWifiInterface( + wifi: DevicesTestData.createWifiInfo(signalStrength: -55), + ); + expect(wifi.signalStrength, -55); + }); + + test('band delegates to wifi', () { + final wifi = DevicesTestData.createWifiInterface( + wifi: DevicesTestData.createWifiInfo(band: '6GHz'), + ); + expect(wifi.band, '6GHz'); + }); + }); +} diff --git a/test/page/_shared/models/device_ui_model_test.dart b/test/page/_shared/models/device_ui_model_test.dart deleted file mode 100644 index ecebbafde..000000000 --- a/test/page/_shared/models/device_ui_model_test.dart +++ /dev/null @@ -1,454 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; - -void main() { - // --------------------------------------------------------------------------- - // DeviceInterfaceInfo - // --------------------------------------------------------------------------- - - group('DeviceInterfaceInfo', () { - test('equality based on all fields', () { - const iface1 = DeviceInterfaceInfo( - mac: 'AA:BB:CC:DD:EE:01', - ip: '192.168.1.100', - isWifi: true, - isActive: true, - layer1Interface: 'Device.WiFi.Radio.1', - band: '5GHz', - ssidName: 'MyNetwork', - signalStrength: -55, - ); - const iface2 = DeviceInterfaceInfo( - mac: 'AA:BB:CC:DD:EE:01', - ip: '192.168.1.100', - isWifi: true, - isActive: true, - layer1Interface: 'Device.WiFi.Radio.1', - band: '5GHz', - ssidName: 'MyNetwork', - signalStrength: -55, - ); - const iface3 = DeviceInterfaceInfo( - mac: 'AA:BB:CC:DD:EE:02', // Different MAC - ip: '192.168.1.100', - isWifi: true, - isActive: true, - layer1Interface: 'Device.WiFi.Radio.1', - ); - - expect(iface1, equals(iface2)); - expect(iface1, isNot(equals(iface3))); - }); - - test('props includes all fields', () { - const iface = DeviceInterfaceInfo( - mac: 'AA:BB:CC:DD:EE:01', - ip: '192.168.1.100', - isWifi: true, - isActive: false, - layer1Interface: 'Device.Ethernet.Interface.1', - band: null, - ssidName: null, - signalStrength: null, - ); - - expect(iface.props, hasLength(8)); - expect(iface.props, contains('AA:BB:CC:DD:EE:01')); - expect(iface.props, contains('192.168.1.100')); - expect(iface.props, contains(true)); // isWifi - expect(iface.props, contains(false)); // isActive - }); - }); - - // --------------------------------------------------------------------------- - // DeviceUIModel — Multi-Interface Getters - // --------------------------------------------------------------------------- - - group('DeviceUIModel — multi-interface getters', () { - const baseDevice = DeviceUIModel( - mac: 'AA:BB:CC:DD:EE:01', - ip: '192.168.1.100', - hostName: 'MacBook', - isActive: true, - isWifi: true, - signalStrength: -55, - ); - - const additionalInterface = DeviceInterfaceInfo( - mac: 'AA:BB:CC:DD:EE:02', - ip: '192.168.1.101', - isWifi: false, - isActive: true, - layer1Interface: 'Device.Ethernet.Interface.1', - ); - - test('hasMultipleInterfaces is false when no additional interfaces', () { - expect(baseDevice.hasMultipleInterfaces, isFalse); - }); - - test('hasMultipleInterfaces is true when additional interfaces exist', () { - final multiDevice = - baseDevice.copyWith(additionalInterfaces: [additionalInterface]); - expect(multiDevice.hasMultipleInterfaces, isTrue); - }); - - test('allMacAddresses returns only primary MAC when no additional', () { - expect(baseDevice.allMacAddresses, ['AA:BB:CC:DD:EE:01']); - }); - - test('allMacAddresses returns primary + additional MACs', () { - const secondInterface = DeviceInterfaceInfo( - mac: 'AA:BB:CC:DD:EE:03', - ip: '192.168.1.102', - isWifi: true, - isActive: true, - layer1Interface: 'Device.WiFi.Radio.2', - ); - final multiDevice = baseDevice.copyWith( - additionalInterfaces: [additionalInterface, secondInterface], - ); - - expect(multiDevice.allMacAddresses, [ - 'AA:BB:CC:DD:EE:01', - 'AA:BB:CC:DD:EE:02', - 'AA:BB:CC:DD:EE:03', - ]); - }); - - test('interfaceCount is 1 when no additional interfaces', () { - expect(baseDevice.interfaceCount, 1); - }); - - test('interfaceCount is 1 + additionalInterfaces.length', () { - final multiDevice = - baseDevice.copyWith(additionalInterfaces: [additionalInterface]); - expect(multiDevice.interfaceCount, 2); - }); - - test('hasAnyActiveInterface is true when primary is active', () { - expect(baseDevice.hasAnyActiveInterface, isTrue); - }); - - test('hasAnyActiveInterface is true when only additional is active', () { - const inactiveDevice = DeviceUIModel( - mac: 'AA:BB:CC:DD:EE:01', - ip: '192.168.1.100', - hostName: 'MacBook', - isActive: false, - isWifi: true, - ); - final multiDevice = inactiveDevice.copyWith( - additionalInterfaces: [additionalInterface], // isActive: true - ); - expect(multiDevice.hasAnyActiveInterface, isTrue); - }); - - test('hasAnyActiveInterface is false when all interfaces inactive', () { - const inactiveDevice = DeviceUIModel( - mac: 'AA:BB:CC:DD:EE:01', - ip: '192.168.1.100', - hostName: 'MacBook', - isActive: false, - isWifi: true, - ); - const inactiveInterface = DeviceInterfaceInfo( - mac: 'AA:BB:CC:DD:EE:02', - ip: '192.168.1.101', - isWifi: false, - isActive: false, - layer1Interface: 'Device.Ethernet.Interface.1', - ); - final multiDevice = inactiveDevice.copyWith( - additionalInterfaces: [inactiveInterface], - ); - expect(multiDevice.hasAnyActiveInterface, isFalse); - }); - }); - - // --------------------------------------------------------------------------- - // DeviceUIModel — copyWith - // --------------------------------------------------------------------------- - - group('DeviceUIModel — copyWith', () { - const baseDevice = DeviceUIModel( - mac: 'AA:BB:CC:DD:EE:01', - ip: '192.168.1.100', - hostName: 'MacBook', - isActive: true, - isWifi: true, - ); - - test('copyWith preserves unchanged fields', () { - final copied = baseDevice.copyWith(ip: '192.168.1.200'); - expect(copied.mac, 'AA:BB:CC:DD:EE:01'); - expect(copied.hostName, 'MacBook'); - expect(copied.isActive, isTrue); - expect(copied.isWifi, isTrue); - expect(copied.ip, '192.168.1.200'); - }); - - test('copyWith additionalInterfaces replaces list', () { - const iface = DeviceInterfaceInfo( - mac: 'AA:BB:CC:DD:EE:02', - ip: '192.168.1.101', - isWifi: false, - isActive: true, - layer1Interface: 'Device.Ethernet.Interface.1', - ); - final copied = baseDevice.copyWith(additionalInterfaces: [iface]); - expect(copied.additionalInterfaces, [iface]); - expect(copied.hasMultipleInterfaces, isTrue); - }); - - test('copyWith returns equal object when no changes', () { - final copied = baseDevice.copyWith(); - expect(copied, equals(baseDevice)); - }); - }); - - // --------------------------------------------------------------------------- - // DeviceUIModel — Equatable - // --------------------------------------------------------------------------- - - group('DeviceUIModel — Equatable', () { - test('equality includes additionalInterfaces', () { - const device1 = DeviceUIModel( - mac: 'AA:BB:CC:DD:EE:01', - ip: '192.168.1.100', - hostName: 'MacBook', - isActive: true, - isWifi: true, - ); - const device2 = DeviceUIModel( - mac: 'AA:BB:CC:DD:EE:01', - ip: '192.168.1.100', - hostName: 'MacBook', - isActive: true, - isWifi: true, - ); - const iface = DeviceInterfaceInfo( - mac: 'AA:BB:CC:DD:EE:02', - ip: '192.168.1.101', - isWifi: false, - isActive: true, - layer1Interface: 'Device.Ethernet.Interface.1', - ); - const device3 = DeviceUIModel( - mac: 'AA:BB:CC:DD:EE:01', - ip: '192.168.1.100', - hostName: 'MacBook', - isActive: true, - isWifi: true, - additionalInterfaces: [iface], - ); - - expect(device1, equals(device2)); - expect(device1, isNot(equals(device3))); - }); - }); - - // --------------------------------------------------------------------------- - // DeviceUIModel — Other Getters - // --------------------------------------------------------------------------- - - group('DeviceUIModel — displayName', () { - test('displayName prefers friendlyName over hostName', () { - const device = DeviceUIModel( - mac: 'AA:BB:CC:DD:EE:01', - ip: '192.168.1.100', - hostName: 'macbook-pro', - friendlyName: "Austin's MacBook", - isActive: true, - isWifi: true, - ); - expect(device.displayName, "Austin's MacBook"); - }); - - test('displayName uses hostName when friendlyName is empty', () { - const device = DeviceUIModel( - mac: 'AA:BB:CC:DD:EE:01', - ip: '192.168.1.100', - hostName: 'macbook-pro', - friendlyName: '', - isActive: true, - isWifi: true, - ); - expect(device.displayName, 'macbook-pro'); - }); - - test('displayName falls back to MAC when hostName is empty', () { - const device = DeviceUIModel( - mac: 'AA:BB:CC:DD:EE:01', - ip: '192.168.1.100', - hostName: '', - isActive: true, - isWifi: true, - ); - expect(device.displayName, 'AA:BB:CC:DD:EE:01'); - }); - }); - - group('DeviceUIModel — isClientDevice', () { - test('isClientDevice is true for null deviceRole', () { - const device = DeviceUIModel( - mac: 'AA:BB:CC:DD:EE:01', - ip: '192.168.1.100', - hostName: 'iPhone', - isActive: true, - isWifi: true, - deviceRole: null, - ); - expect(device.isClientDevice, isTrue); - }); - - test('isClientDevice is false for master role', () { - const device = DeviceUIModel( - mac: 'AA:BB:CC:DD:EE:01', - ip: '192.168.1.100', - hostName: 'MR7500', - isActive: true, - isWifi: false, - deviceRole: 'master', - ); - expect(device.isClientDevice, isFalse); - }); - - test('isClientDevice is false for slave role', () { - const device = DeviceUIModel( - mac: 'AA:BB:CC:DD:EE:01', - ip: '192.168.1.100', - hostName: 'MX5500', - isActive: true, - isWifi: false, - deviceRole: 'slave', - ); - expect(device.isClientDevice, isFalse); - }); - }); - - // --------------------------------------------------------------------------- - // DeviceUIModelListExt — Extension Methods - // --------------------------------------------------------------------------- - - group('DeviceUIModelListExt', () { - const clientWifi = DeviceUIModel( - mac: 'AA:BB:CC:DD:EE:01', - ip: '192.168.1.100', - hostName: 'iPhone', - isActive: true, - isWifi: true, - deviceRole: null, - ); - - const clientWired = DeviceUIModel( - mac: 'AA:BB:CC:DD:EE:02', - ip: '192.168.1.101', - hostName: 'Desktop', - isActive: true, - isWifi: false, - deviceRole: 'client', - ); - - const masterNode = DeviceUIModel( - mac: 'AA:BB:CC:DD:EE:10', - ip: '192.168.1.1', - hostName: 'MR7500', - isActive: true, - isWifi: false, - deviceRole: 'master', - ); - - const slaveNode1 = DeviceUIModel( - mac: 'AA:BB:CC:DD:EE:11', - ip: '192.168.1.2', - hostName: 'MX5500-1', - isActive: true, - isWifi: false, - deviceRole: 'slave', - ); - - const slaveNode2 = DeviceUIModel( - mac: 'AA:BB:CC:DD:EE:12', - ip: '192.168.1.3', - hostName: 'MX5500-2', - isActive: true, - isWifi: false, - deviceRole: 'slave', - ); - - final allDevices = [ - clientWifi, - clientWired, - masterNode, - slaveNode1, - slaveNode2, - ]; - - test('clientDevices returns only non-mesh devices', () { - final clients = allDevices.clientDevices; - - expect(clients, hasLength(2)); - expect(clients, contains(clientWifi)); - expect(clients, contains(clientWired)); - expect(clients, isNot(contains(masterNode))); - expect(clients, isNot(contains(slaveNode1))); - }); - - test('meshNodes returns only master and slave devices', () { - final meshes = allDevices.meshNodes; - - expect(meshes, hasLength(3)); - expect(meshes, contains(masterNode)); - expect(meshes, contains(slaveNode1)); - expect(meshes, contains(slaveNode2)); - expect(meshes, isNot(contains(clientWifi))); - }); - - test('masterNode returns the master device', () { - final master = allDevices.masterNode; - - expect(master, isNotNull); - expect(master, equals(masterNode)); - }); - - test('masterNode returns null when no master exists', () { - final devicesNoMaster = [clientWifi, clientWired, slaveNode1]; - final master = devicesNoMaster.masterNode; - - expect(master, isNull); - }); - - test('slaveNodes returns all slave devices', () { - final slaves = allDevices.slaveNodes; - - expect(slaves, hasLength(2)); - expect(slaves, contains(slaveNode1)); - expect(slaves, contains(slaveNode2)); - expect(slaves, isNot(contains(masterNode))); - }); - - test('slaveNodes returns empty list when no slaves', () { - final devicesNoSlaves = [clientWifi, masterNode]; - final slaves = devicesNoSlaves.slaveNodes; - - expect(slaves, isEmpty); - }); - - test('extensions work on empty list', () { - final List emptyList = []; - - expect(emptyList.clientDevices, isEmpty); - expect(emptyList.meshNodes, isEmpty); - expect(emptyList.masterNode, isNull); - expect(emptyList.slaveNodes, isEmpty); - }); - - test('clientDevices returns all when no mesh nodes', () { - final clientsOnly = [clientWifi, clientWired]; - final clients = clientsOnly.clientDevices; - - expect(clients, hasLength(2)); - expect(clients, equals(clientsOnly)); - }); - }); -} diff --git a/test/page/_shared/models/mesh_network_test.dart b/test/page/_shared/models/mesh_network_test.dart new file mode 100644 index 000000000..fedfa84f6 --- /dev/null +++ b/test/page/_shared/models/mesh_network_test.dart @@ -0,0 +1,428 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_network.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; + +import '../../../mocks/test_data/devices_test_data.dart'; + +void main() { + group('MeshNetwork', () { + // ========================================================================= + // Accessors + // ========================================================================= + + group('allNodes', () { + test('returns master only when no slaves', () { + final network = DevicesTestData.createSingleNodeNetwork(); + + expect(network.allNodes, hasLength(1)); + expect(network.allNodes.first, isA()); + }); + + test('returns master + slaves in mesh network', () { + final network = DevicesTestData.createMultiSlaveMeshNetwork(); + + expect(network.allNodes, hasLength(3)); + expect(network.allNodes[0], isA()); + expect(network.allNodes[1], isA()); + expect(network.allNodes[2], isA()); + }); + }); + + group('allClients', () { + test('returns master clients in single-node network', () { + final network = DevicesTestData.createSingleNodeNetwork(); + + expect(network.allClients, hasLength(2)); + }); + + test('combines clients from all nodes', () { + final network = DevicesTestData.createMeshNetwork(); + + // 1 client on master + 1 client on slave + expect(network.allClients, hasLength(2)); + }); + + test('includes unassigned clients', () { + final network = DevicesTestData.createNetworkWithUnassignedClients(); + + expect(network.allClients, hasLength(2)); + expect(network.unassignedClients, hasLength(2)); + }); + }); + + group('client counts', () { + test('totalClientCount returns correct count', () { + final network = DevicesTestData.createSingleNodeNetwork(); + expect(network.totalClientCount, 2); + }); + + test('onlineClientCount filters active clients only', () { + final mixedClients = [ + DevicesTestData.createWifiClient(isActive: true), + DevicesTestData.createWiredClient(isActive: false), + DevicesTestData.createOfflineClient(), + ]; + final network = DevicesTestData.createSingleNodeNetwork( + masterClients: mixedClients, + ); + + expect(network.onlineClientCount, 1); + expect(network.offlineClientCount, 2); + }); + + test('wifiClientCount counts online WiFi clients', () { + final clients = [ + DevicesTestData.createWifiClient(isActive: true), + DevicesTestData.createWifiClient( + mac: '11:22:33:44:55:99', isActive: false), + DevicesTestData.createWiredClient(isActive: true), + ]; + final network = DevicesTestData.createSingleNodeNetwork( + masterClients: clients, + ); + + expect(network.wifiClientCount, 1); + expect(network.wiredClientCount, 1); + }); + }); + + group('hasMesh', () { + test('returns false for single-node network', () { + final network = DevicesTestData.createSingleNodeNetwork(); + expect(network.hasMesh, isFalse); + }); + + test('returns true when slaves exist', () { + final network = DevicesTestData.createMeshNetwork(); + expect(network.hasMesh, isTrue); + }); + }); + + group('nodeCount', () { + test('returns 1 for single-node network', () { + final network = DevicesTestData.createSingleNodeNetwork(); + expect(network.nodeCount, 1); + }); + + test('returns correct count for mesh network', () { + final network = DevicesTestData.createMultiSlaveMeshNetwork(); + expect(network.nodeCount, 3); + }); + }); + + // ========================================================================= + // Lookups + // ========================================================================= + + group('findNode', () { + test('finds master by deviceId', () { + final network = DevicesTestData.createMeshNetwork(); + + final found = network.findNode(DevicesTestData.masterMac); + + expect(found, isNotNull); + expect(found, isA()); + }); + + test('finds slave by deviceId', () { + final network = DevicesTestData.createMeshNetwork(); + + final found = network.findNode(DevicesTestData.slaveMac1); + + expect(found, isNotNull); + expect(found, isA()); + }); + + test('finds node by dataElementsId', () { + final slave = DevicesTestData.createWifiSlave( + deviceId: DevicesTestData.slaveMac1, + dataElementsId: 'DE:AA:BB:CC:DD:EE', + ); + final network = MeshNetwork( + master: DevicesTestData.createMaster(), + slaves: [slave], + ); + + final found = network.findNode('DE:AA:BB:CC:DD:EE'); + + expect(found, isNotNull); + expect(found?.deviceId, DevicesTestData.slaveMac1); + }); + + test('is case-insensitive', () { + final network = DevicesTestData.createMeshNetwork(); + + final lowercase = + network.findNode(DevicesTestData.masterMac.toLowerCase()); + final uppercase = + network.findNode(DevicesTestData.masterMac.toUpperCase()); + + expect(lowercase, isNotNull); + expect(uppercase, isNotNull); + expect(lowercase, equals(uppercase)); + }); + + test('returns null when not found', () { + final network = DevicesTestData.createSingleNodeNetwork(); + + expect(network.findNode('XX:XX:XX:XX:XX:XX'), isNull); + }); + }); + + group('findClient', () { + test('finds client by MAC address', () { + final network = DevicesTestData.createSingleNodeNetwork(); + + final found = network.findClient(DevicesTestData.clientMac1); + + expect(found, isNotNull); + expect(found?.mac, DevicesTestData.clientMac1); + }); + + test('finds client by additional interface MAC', () { + final multiClient = DevicesTestData.createMultiInterfaceClient( + mac: DevicesTestData.clientMac1, + additionalInterfaces: [ + DevicesTestData.createWiredInterface(mac: 'FF:FF:FF:FF:FF:01'), + ], + ); + final network = DevicesTestData.createSingleNodeNetwork( + masterClients: [multiClient], + ); + + final found = network.findClient('FF:FF:FF:FF:FF:01'); + + expect(found, isNotNull); + expect(found?.mac, DevicesTestData.clientMac1); + }); + + test('is case-insensitive', () { + final network = DevicesTestData.createSingleNodeNetwork(); + + final lowercase = + network.findClient(DevicesTestData.clientMac1.toLowerCase()); + final uppercase = + network.findClient(DevicesTestData.clientMac1.toUpperCase()); + + expect(lowercase, isNotNull); + expect(uppercase, isNotNull); + }); + + test('returns null when not found', () { + final network = DevicesTestData.createSingleNodeNetwork(); + + expect(network.findClient('XX:XX:XX:XX:XX:XX'), isNull); + }); + }); + + group('findParentNode', () { + test('returns master when parentNodeId is null', () { + final client = DevicesTestData.createWifiClient(parentNodeId: null); + final network = DevicesTestData.createSingleNodeNetwork( + masterClients: [client], + ); + + final parent = network.findParentNode(client); + + expect(parent, isA()); + }); + + test('returns correct slave node when parentNodeId is set', () { + final client = DevicesTestData.createSlaveConnectedClient( + parentNodeId: DevicesTestData.slaveMac1, + ); + final network = DevicesTestData.createMeshNetwork( + slaveClients: [client], + ); + + final parent = network.findParentNode(client); + + expect(parent, isA()); + expect(parent?.deviceId, DevicesTestData.slaveMac1); + }); + + test('returns null when parentNodeId not found', () { + final client = DevicesTestData.createWifiClient( + parentNodeId: 'XX:XX:XX:XX:XX:XX', + ); + final network = DevicesTestData.createSingleNodeNetwork( + masterClients: [client], + ); + + final parent = network.findParentNode(client); + + expect(parent, isNull); + }); + }); + + group('clientsForNode', () { + test('returns clients for master node', () { + final network = DevicesTestData.createSingleNodeNetwork(); + + final clients = network.clientsForNode(DevicesTestData.masterMac); + + expect(clients, hasLength(2)); + }); + + test('returns clients for slave node', () { + final network = DevicesTestData.createMeshNetwork(); + + final clients = network.clientsForNode(DevicesTestData.slaveMac1); + + expect(clients, hasLength(1)); + }); + + test('returns empty list when node not found', () { + final network = DevicesTestData.createSingleNodeNetwork(); + + final clients = network.clientsForNode('XX:XX:XX:XX:XX:XX'); + + expect(clients, isEmpty); + }); + }); + + group('clientsByNode', () { + test('groups clients by node ID', () { + final network = DevicesTestData.createMeshNetwork(); + + final byNode = network.clientsByNode; + + expect(byNode.containsKey(DevicesTestData.masterMac), isTrue); + expect(byNode.containsKey(DevicesTestData.slaveMac1), isTrue); + }); + + test('includes _unassigned key when unassigned clients exist', () { + final network = DevicesTestData.createNetworkWithUnassignedClients(); + + final byNode = network.clientsByNode; + + expect(byNode.containsKey('_unassigned'), isTrue); + expect(byNode['_unassigned'], hasLength(2)); + }); + + test('does not include _unassigned key when no unassigned clients', () { + final network = DevicesTestData.createSingleNodeNetwork(); + + final byNode = network.clientsByNode; + + expect(byNode.containsKey('_unassigned'), isFalse); + }); + }); + + // ========================================================================= + // Equatable + // ========================================================================= + + group('Equatable', () { + test('equal networks are equal', () { + final network1 = DevicesTestData.createSingleNodeNetwork(); + final network2 = DevicesTestData.createSingleNodeNetwork(); + + expect(network1, equals(network2)); + }); + + test('different masters are not equal', () { + final network1 = DevicesTestData.createSingleNodeNetwork( + master: DevicesTestData.createMaster(deviceId: 'AA:AA:AA:AA:AA:01'), + ); + final network2 = DevicesTestData.createSingleNodeNetwork( + master: DevicesTestData.createMaster(deviceId: 'AA:AA:AA:AA:AA:02'), + ); + + expect(network1, isNot(equals(network2))); + }); + + test('different slaves are not equal', () { + final network1 = DevicesTestData.createMeshNetwork(); + final network2 = DevicesTestData.createMultiSlaveMeshNetwork(); + + expect(network1, isNot(equals(network2))); + }); + + test('different unassigned clients are not equal', () { + final network1 = DevicesTestData.createNetworkWithUnassignedClients( + unassignedClients: [DevicesTestData.createWifiClient()], + ); + final network2 = DevicesTestData.createNetworkWithUnassignedClients( + unassignedClients: [ + DevicesTestData.createWifiClient(), + DevicesTestData.createWiredClient(), + ], + ); + + expect(network1, isNot(equals(network2))); + }); + }); + + // ========================================================================= + // copyWith + // ========================================================================= + + group('copyWith', () { + test('preserves unchanged fields', () { + final original = DevicesTestData.createMeshNetwork(); + final copied = original.copyWith(); + + expect(copied, equals(original)); + }); + + test('updates master', () { + final original = DevicesTestData.createMeshNetwork(); + final newMaster = DevicesTestData.createMaster(deviceId: 'NEW:MAC'); + final copied = original.copyWith(master: newMaster); + + expect(copied.master.deviceId, 'NEW:MAC'); + expect(copied.slaves, equals(original.slaves)); + }); + + test('updates slaves', () { + final original = DevicesTestData.createMeshNetwork(); + final copied = original.copyWith(slaves: []); + + expect(copied.slaves, isEmpty); + expect(copied.master, equals(original.master)); + }); + + test('updates unassignedClients', () { + final original = DevicesTestData.createSingleNodeNetwork(); + final unassigned = [DevicesTestData.createWifiClient()]; + final copied = original.copyWith(unassignedClients: unassigned); + + expect(copied.unassignedClients, hasLength(1)); + }); + }); + + // ========================================================================= + // Edge Cases + // ========================================================================= + + group('edge cases', () { + test('empty network works correctly', () { + final network = DevicesTestData.createEmptyNetwork(); + + expect(network.allClients, isEmpty); + expect(network.totalClientCount, 0); + expect(network.onlineClientCount, 0); + expect(network.offlineClientCount, 0); + expect(network.wifiClientCount, 0); + expect(network.wiredClientCount, 0); + expect(network.hasMesh, isFalse); + expect(network.nodeCount, 1); + }); + + test('all clients offline returns zero online counts', () { + final offlineClients = [ + DevicesTestData.createOfflineClient(mac: '11:11:11:11:11:01'), + DevicesTestData.createOfflineClient(mac: '11:11:11:11:11:02'), + ]; + final network = DevicesTestData.createSingleNodeNetwork( + masterClients: offlineClients, + ); + + expect(network.totalClientCount, 2); + expect(network.onlineClientCount, 0); + expect(network.offlineClientCount, 2); + }); + }); + }); +} diff --git a/test/page/_shared/models/node_entity_test.dart b/test/page/_shared/models/node_entity_test.dart new file mode 100644 index 000000000..ac81bcc08 --- /dev/null +++ b/test/page/_shared/models/node_entity_test.dart @@ -0,0 +1,368 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:privacy_gui/page/_shared/models/backhaul_info.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; + +import '../../../mocks/test_data/devices_test_data.dart'; + +void main() { + // =========================================================================== + // MasterNode + // =========================================================================== + + group('MasterNode', () { + group('isMaster', () { + test('returns true', () { + final master = DevicesTestData.createMaster(); + expect(master.isMaster, isTrue); + }); + }); + + group('roleLabel', () { + test('returns Master', () { + final master = DevicesTestData.createMaster(); + expect(master.roleLabel, 'Master'); + }); + }); + + group('displayName', () { + test('returns friendlyName when set', () { + final node = DevicesTestData.createMaster( + friendlyName: 'My Router', + hostName: 'Linksys-Router', + model: 'MR7500', + deviceId: DevicesTestData.masterMac, + ); + + expect(node.displayName, 'My Router'); + }); + + test('returns hostName when friendlyName is null', () { + final node = DevicesTestData.createMaster( + friendlyName: null, + hostName: 'Linksys-Router', + model: 'MR7500', + ); + + expect(node.displayName, 'Linksys-Router'); + }); + + test('returns hostName when friendlyName is empty', () { + final node = MasterNode( + deviceId: DevicesTestData.masterMac, + friendlyName: '', + hostName: 'Linksys-Router', + model: 'MR7500', + ); + + expect(node.displayName, 'Linksys-Router'); + }); + + test('returns model when friendlyName and hostName are null/empty', () { + final node = MasterNode( + deviceId: DevicesTestData.masterMac, + friendlyName: null, + hostName: null, + model: 'MR7500', + ); + + expect(node.displayName, 'MR7500'); + }); + + test('returns deviceId when all others are null/empty', () { + final node = MasterNode( + deviceId: DevicesTestData.masterMac, + friendlyName: null, + hostName: null, + model: '', + ); + + expect(node.displayName, DevicesTestData.masterMac); + }); + }); + + group('NetworkEntity implementation', () { + test('id returns deviceId', () { + final node = DevicesTestData.createMaster( + deviceId: DevicesTestData.masterMac, + ); + + expect(node.id, DevicesTestData.masterMac); + }); + + test('isOnline always returns true', () { + final node = DevicesTestData.createMaster(); + expect(node.isOnline, isTrue); + }); + }); + + group('connectedDeviceCount', () { + test('returns number of connected clients', () { + final node = DevicesTestData.createMaster( + connectedClients: [ + DevicesTestData.createWifiClient(), + DevicesTestData.createWiredClient(), + ], + ); + + expect(node.connectedDeviceCount, 2); + }); + + test('returns 0 when no clients', () { + final node = DevicesTestData.createMaster(connectedClients: []); + expect(node.connectedDeviceCount, 0); + }); + }); + + group('copyWith', () { + test('preserves unchanged fields', () { + final original = DevicesTestData.createMaster(); + final copied = original.copyWith(); + + expect(copied.deviceId, original.deviceId); + expect(copied.model, original.model); + expect(copied.manufacturer, original.manufacturer); + }); + + test('updates specified fields', () { + final original = DevicesTestData.createMaster(); + final copied = original.copyWith( + friendlyName: 'New Name', + model: 'MR8000', + ); + + expect(copied.friendlyName, 'New Name'); + expect(copied.model, 'MR8000'); + expect(copied.deviceId, original.deviceId); + }); + + test('carries hostsDeviceId (UUID) through construction and equality', + () { + final withUuid = + DevicesTestData.createMaster(hostsDeviceId: 'uuid-1234'); + expect(withUuid.hostsDeviceId, 'uuid-1234'); + + final sameUuid = + DevicesTestData.createMaster(hostsDeviceId: 'uuid-1234'); + final otherUuid = + DevicesTestData.createMaster(hostsDeviceId: 'uuid-9999'); + + // hostsDeviceId participates in Equatable props. + expect(withUuid, equals(sameUuid)); + expect(withUuid, isNot(equals(otherUuid))); + }); + }); + }); + + // =========================================================================== + // SlaveNode + // =========================================================================== + + group('SlaveNode', () { + group('isMaster', () { + test('returns false', () { + final slave = DevicesTestData.createWifiSlave(); + expect(slave.isMaster, isFalse); + }); + }); + + group('roleLabel', () { + test('returns Slave', () { + final slave = DevicesTestData.createWifiSlave(); + expect(slave.roleLabel, 'Slave'); + }); + }); + + group('displayName', () { + test('follows same priority as MasterNode', () { + final withFriendly = DevicesTestData.createWifiSlave( + friendlyName: 'Living Room Extender', + hostName: 'Extender-1', + ); + final withHost = DevicesTestData.createWifiSlave( + friendlyName: null, + hostName: 'Extender-1', + ); + + expect(withFriendly.displayName, 'Living Room Extender'); + expect(withHost.displayName, 'Extender-1'); + }); + }); + + group('backhaul properties', () { + test('isEthernetBackhaul returns true for Ethernet backhaul', () { + final ethernetSlave = DevicesTestData.createEthernetSlave(); + final wifiSlave = DevicesTestData.createWifiSlave(); + + expect(ethernetSlave.isEthernetBackhaul, isTrue); + expect(wifiSlave.isEthernetBackhaul, isFalse); + }); + + test('hasBackhaul returns true when backhaul has info', () { + final withBackhaul = DevicesTestData.createWifiSlave(); + final noBackhaul = SlaveNode( + deviceId: DevicesTestData.slaveMac1, + model: 'MR7500', + backhaul: DevicesTestData.emptyBackhaul, + ); + + expect(withBackhaul.hasBackhaul, isTrue); + expect(noBackhaul.hasBackhaul, isFalse); + }); + }); + + group('copyWith', () { + test('preserves unchanged fields', () { + final original = DevicesTestData.createWifiSlave(); + final copied = original.copyWith(); + + expect(copied.deviceId, original.deviceId); + expect(copied.backhaul, original.backhaul); + }); + + test('updates backhaul', () { + final original = DevicesTestData.createWifiSlave(); + final newBackhaul = DevicesTestData.createEthernetBackhaul(); + final copied = original.copyWith(backhaul: newBackhaul); + + expect(copied.isEthernetBackhaul, isTrue); + }); + }); + }); + + // =========================================================================== + // List Extensions + // =========================================================================== + + group('List extensions', () { + late List nodes; + + setUp(() { + nodes = [ + DevicesTestData.createMaster(), + DevicesTestData.createWifiSlave(deviceId: DevicesTestData.slaveMac1), + DevicesTestData.createEthernetSlave( + deviceId: DevicesTestData.slaveMac2), + ]; + }); + + test('master returns the MasterNode', () { + final master = nodes.master; + + expect(master, isNotNull); + expect(master, isA()); + }); + + test('master returns null when no MasterNode', () { + final slavesOnly = [ + DevicesTestData.createWifiSlave(), + ]; + + expect(slavesOnly.master, isNull); + }); + + test('slaves returns only SlaveNodes', () { + final slaves = nodes.slaves; + + expect(slaves, hasLength(2)); + expect(slaves.first.deviceId, DevicesTestData.slaveMac1); + expect(slaves.last.deviceId, DevicesTestData.slaveMac2); + }); + + test('hasMesh returns true when slaves exist', () { + expect(nodes.hasMesh, isTrue); + }); + + test('hasMesh returns false when no slaves', () { + final masterOnly = [ + DevicesTestData.createMaster(), + ]; + + expect(masterOnly.hasMesh, isFalse); + }); + }); + + // =========================================================================== + // BackhaulInfo + // =========================================================================== + + group('BackhaulInfo', () { + test('isEthernet returns true for Ethernet linkType', () { + final ethernet = DevicesTestData.createEthernetBackhaul(); + final wifi = DevicesTestData.createWifiBackhaul(); + + expect(ethernet.isEthernet, isTrue); + expect(wifi.isEthernet, isFalse); + }); + + test('isWifi returns true for non-Ethernet linkType', () { + final wifi = DevicesTestData.createWifiBackhaul(); + final ethernet = DevicesTestData.createEthernetBackhaul(); + + expect(wifi.isWifi, isTrue); + expect(ethernet.isWifi, isFalse); + }); + + test('hasInfo returns true when mediaType is not empty', () { + final withInfo = DevicesTestData.createWifiBackhaul(); + const noInfo = BackhaulInfo(mediaType: ''); + + expect(withInfo.hasInfo, isTrue); + expect(noInfo.hasInfo, isFalse); + }); + + test('Equatable compares all fields', () { + final backhaul1 = DevicesTestData.createWifiBackhaul(signalStrength: -55); + final backhaul2 = DevicesTestData.createWifiBackhaul(signalStrength: -55); + final backhaul3 = DevicesTestData.createWifiBackhaul(signalStrength: -70); + + expect(backhaul1, equals(backhaul2)); + expect(backhaul1, isNot(equals(backhaul3))); + }); + }); + + // =========================================================================== + // Equatable + // =========================================================================== + + group('NodeEntity Equatable', () { + test('equal MasterNodes are equal', () { + final node1 = DevicesTestData.createMaster( + deviceId: DevicesTestData.masterMac, + model: 'MR7500', + ); + final node2 = DevicesTestData.createMaster( + deviceId: DevicesTestData.masterMac, + model: 'MR7500', + ); + + expect(node1, equals(node2)); + }); + + test('different deviceId makes nodes not equal', () { + final node1 = DevicesTestData.createMaster(deviceId: 'AA:AA:AA:AA:AA:01'); + final node2 = DevicesTestData.createMaster(deviceId: 'AA:AA:AA:AA:AA:02'); + + expect(node1, isNot(equals(node2))); + }); + + test('equal SlaveNodes are equal', () { + final node1 = DevicesTestData.createWifiSlave( + deviceId: DevicesTestData.slaveMac1, + ); + final node2 = DevicesTestData.createWifiSlave( + deviceId: DevicesTestData.slaveMac1, + ); + + expect(node1, equals(node2)); + }); + + test('different connectedClients makes nodes not equal', () { + final node1 = DevicesTestData.createMaster(connectedClients: []); + final node2 = DevicesTestData.createMaster( + connectedClients: [DevicesTestData.createWifiClient()], + ); + + expect(node1, isNot(equals(node2))); + }); + }); +} diff --git a/test/page/_shared/models/wifi_radio_ui_model_test.dart b/test/page/_shared/models/wifi_radio_ui_model_test.dart index 69b2b4fd0..1cf0e8cfc 100644 --- a/test/page/_shared/models/wifi_radio_ui_model_test.dart +++ b/test/page/_shared/models/wifi_radio_ui_model_test.dart @@ -28,6 +28,33 @@ void main() { expect(model.accessPoints, isEmpty); }); + test('possibleChannels defaults to empty list', () { + final model = WifiRadioUIModel( + instancePath: 'Device.WiFi.Radio.1.', + band: '2.4GHz', + enable: true, + transmitPower: 80, + maxBitRate: 300, + channel: 6, + autoChannelEnable: true, + channelBandwidth: '20MHz', + supportedStandards: 'ax', + ); + + expect(model.possibleChannels, isA>()); + expect(model.possibleChannels, isEmpty); + }); + + test('possibleChannels is retained and included in equality', () { + final model1 = _createRadio(possibleChannels: const [1, 6, 11]); + final model2 = _createRadio(possibleChannels: const [1, 6, 11]); + final model3 = _createRadio(possibleChannels: const [1, 6]); + + expect(model1.possibleChannels, [1, 6, 11]); + expect(model1, equals(model2)); + expect(model1, isNot(equals(model3))); + }); + test('accessPoints defaults to empty list', () { final model = WifiRadioUIModel( instancePath: 'Device.WiFi.Radio.1.', @@ -196,6 +223,7 @@ WifiRadioUIModel _createRadio({ bool autoChannelEnable = true, String channelBandwidth = '20MHz', String supportedStandards = 'ax', + List possibleChannels = const [], List accessPoints = const [], }) { return WifiRadioUIModel( @@ -208,6 +236,7 @@ WifiRadioUIModel _createRadio({ autoChannelEnable: autoChannelEnable, channelBandwidth: channelBandwidth, supportedStandards: supportedStandards, + possibleChannels: possibleChannels, accessPoints: accessPoints, ); } diff --git a/test/page/_shared/providers/usp_device_analytics_notifier_test.dart b/test/page/_shared/providers/usp_device_analytics_notifier_test.dart index 0fb8a898e..0a0a3ce39 100644 --- a/test/page/_shared/providers/usp_device_analytics_notifier_test.dart +++ b/test/page/_shared/providers/usp_device_analytics_notifier_test.dart @@ -1,8 +1,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; import 'package:privacy_gui/page/_shared/models/device_analytics_state.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_network.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; +import 'package:privacy_gui/page/_shared/models/wifi_connection_info.dart'; import 'package:privacy_gui/page/_shared/providers/usp_device_analytics_notifier.dart'; import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; @@ -20,46 +23,65 @@ class _TestDevicesDataNotifier extends DevicesDataNotifier { } void main() { - const wifiDevice5g = DeviceUIModel( + final wifiDevice5g = ClientDevice( mac: 'AA:BB:CC:DD:EE:01', ip: '192.168.1.100', hostName: 'Phone', isActive: true, - isWifi: true, - band: '5GHz', - signalStrength: -55, // level 3 (excellent, >= -65) + connectionType: ConnectionType.wifi, + wifi: const WifiConnectionInfo( + band: '5GHz', + signalStrength: -55, // level 3 (excellent, >= -65) + ), ); - const wifiDevice24g = DeviceUIModel( + final wifiDevice24g = ClientDevice( mac: 'AA:BB:CC:DD:EE:02', ip: '192.168.1.101', hostName: 'Tablet', isActive: true, - isWifi: true, - band: '2.4GHz', - signalStrength: -75, // level 1 (fair, -71..-78) + connectionType: ConnectionType.wifi, + wifi: const WifiConnectionInfo( + band: '2.4GHz', + signalStrength: -75, // level 1 (fair, -71..-78) + ), ); - const wiredDevice = DeviceUIModel( + final wiredDevice = ClientDevice( mac: 'AA:BB:CC:DD:EE:03', ip: '192.168.1.102', hostName: 'Desktop', isActive: true, - isWifi: false, + connectionType: ConnectionType.wired, ); - const offlineDevice = DeviceUIModel( + final offlineDevice = ClientDevice( mac: 'AA:BB:CC:DD:EE:04', ip: '192.168.1.103', hostName: 'Printer', isActive: false, - isWifi: true, - band: '2.4GHz', - signalStrength: -85, + connectionType: ConnectionType.wifi, + wifi: const WifiConnectionInfo( + band: '2.4GHz', + signalStrength: -85, + ), ); final testDevices = [wifiDevice5g, wifiDevice24g, wiredDevice, offlineDevice]; - final testDevicesData = DevicesData(deviceModels: testDevices); + + DevicesData createDevicesData(List clients) { + return DevicesData( + meshNetwork: MeshNetwork( + master: MasterNode( + deviceId: 'GATEWAY', + model: 'Router', + connectedClients: clients, + ), + ), + ); + } + + final testDevicesData = createDevicesData(testDevices); setUp(() { SharedPreferences.setMockInitialValues({}); @@ -176,7 +198,7 @@ void main() { }); test('empty device list produces empty distribution', () async { - final container = createContainer(data: const DevicesData()); + final container = createContainer(data: createDevicesData([])); await waitForAnalytics(container); final state = container.read(uspDeviceAnalyticsProvider); @@ -246,25 +268,29 @@ void main() { test('band signal quality computes average per band', () async { // Two 5GHz devices with different signal strengths - const wifi5a = DeviceUIModel( + final wifi5a = ClientDevice( mac: 'FF:00:00:00:00:01', ip: '192.168.1.200', hostName: 'DeviceA', isActive: true, - isWifi: true, - band: '5GHz', - signalStrength: -30, // quality 1.0 + connectionType: ConnectionType.wifi, + wifi: const WifiConnectionInfo( + band: '5GHz', + signalStrength: -30, // quality 1.0 + ), ); - const wifi5b = DeviceUIModel( + final wifi5b = ClientDevice( mac: 'FF:00:00:00:00:02', ip: '192.168.1.201', hostName: 'DeviceB', isActive: true, - isWifi: true, - band: '5GHz', - signalStrength: -90, // quality 0.0 + connectionType: ConnectionType.wifi, + wifi: const WifiConnectionInfo( + band: '5GHz', + signalStrength: -90, // quality 0.0 + ), ); - const data = DevicesData(deviceModels: [wifi5a, wifi5b]); + final data = createDevicesData([wifi5a, wifi5b]); final container = createContainer(data: data); await waitForAnalytics(container); @@ -273,5 +299,80 @@ void main() { expect(dist.bandSignalQuality['5GHz'], closeTo(0.5, 0.01)); container.dispose(); }); + + test('categorizes by band first, node name only for band-less WiFi', + () async { + // On a REAL mesh, MeshNetworkBuilder maps every node's associated STAs — + // including the master's own clients — into clientToNodeMap, so a master + // WiFi client gets a NON-NULL parentNodeId AND a patched parentNodeName + // (the gateway display name). Categorization must key off the band, not + // parentNodeId, or master WiFi clients collapse under the gateway name. + + // Master WiFi with band — mesh shape: NON-NULL parentNodeId + patched name. + final masterWifiMeshShape = ClientDevice( + mac: 'FF:00:00:00:00:01', + ip: '192.168.1.200', + hostName: 'MasterClient', + isActive: true, + connectionType: ConnectionType.wifi, + parentNodeId: + 'MASTER_NODE_ID', // non-null, as the real builder produces + parentNodeName: 'MyGateway', + wifi: const WifiConnectionInfo( + band: '5GHz', + signalStrength: -50, + ), + ); + // Slave WiFi client WITHOUT a band (band resolution pending #1118) — + // falls through to its node name. + final slaveWifiNoBand = ClientDevice( + mac: 'FF:00:00:00:00:02', + ip: '192.168.1.201', + hostName: 'ChildClient', + isActive: true, + connectionType: ConnectionType.wifi, + parentNodeId: 'CHILD_NODE_ID', + parentNodeName: 'Extender-1', + wifi: const WifiConnectionInfo( + signalStrength: -60, + ), + ); + // Slave Wired client → "Wired". + final slaveWired = ClientDevice( + mac: 'FF:00:00:00:00:03', + ip: '192.168.1.202', + hostName: 'ChildWired', + isActive: true, + connectionType: ConnectionType.wired, + parentNodeId: 'CHILD_NODE_ID', + parentNodeName: 'Extender-1', + ); + // Master Wired client — patched gateway name, non-null parentNodeId → Wired. + final masterWired = ClientDevice( + mac: 'FF:00:00:00:00:04', + ip: '192.168.1.203', + hostName: 'MasterWired', + isActive: true, + connectionType: ConnectionType.wired, + parentNodeId: 'MASTER_NODE_ID', + parentNodeName: 'MyGateway', + ); + final data = createDevicesData( + [masterWifiMeshShape, slaveWifiNoBand, slaveWired, masterWired]); + final container = createContainer(data: data); + await waitForAnalytics(container); + + final dist = container.read(uspDeviceAnalyticsProvider).current!; + // Master WiFi with band: bucketed under its BAND even on a mesh + // (non-null parentNodeId), NOT the gateway name. + expect(dist.bandDistribution['5GHz'], 1); + // Band-less slave WiFi client: grouped under its node name. + expect(dist.bandDistribution['Extender-1'], 1); + // Both wired clients (master + slave): "Wired". + expect(dist.bandDistribution['Wired'], 2); + // The gateway name must never become a category. + expect(dist.bandDistribution.containsKey('MyGateway'), isFalse); + container.dispose(); + }); }); } diff --git a/test/page/_shared/providers/usp_system_monitor_notifier_test.dart b/test/page/_shared/providers/usp_system_monitor_notifier_test.dart index 5dc048cac..1f2288452 100644 --- a/test/page/_shared/providers/usp_system_monitor_notifier_test.dart +++ b/test/page/_shared/providers/usp_system_monitor_notifier_test.dart @@ -87,6 +87,7 @@ void main() { memoryPercent: 60, totalMemoryKb: 1000, freeMemoryKb: 400, + uptimeSeconds: 3600, ); notifier.pushSnapshot(snapshot); @@ -108,6 +109,7 @@ void main() { memoryPercent: i, totalMemoryKb: 1000, freeMemoryKb: 500, + uptimeSeconds: 3600 + i * 60, )); } diff --git a/test/page/_shared/utils/mesh_network_builder_test.dart b/test/page/_shared/utils/mesh_network_builder_test.dart new file mode 100644 index 000000000..ca478c18b --- /dev/null +++ b/test/page/_shared/utils/mesh_network_builder_test.dart @@ -0,0 +1,606 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:privacy_gui/generated/connected_devices.g.dart'; +import 'package:privacy_gui/page/_shared/models/backhaul_info.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; +import 'package:privacy_gui/page/_shared/models/client_connection_detail.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_topology_info.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; +import 'package:privacy_gui/page/_shared/models/wifi_client_ui_model.dart'; +import 'package:privacy_gui/page/_shared/utils/mesh_network_builder.dart'; + +void main() { + // --------------------------------------------------------------------------- + // Test Data Builders + // --------------------------------------------------------------------------- + + ConnectedDevice buildConnectedDevice({ + required String macAddress, + String deviceRole = 'client', + String hostName = '', + String? friendlyName, + String ipAddress = '', + String interface_ = '', + String? interfaceType, + bool isActive = true, + int? signalStrength, + int? lastDataDownlinkRate, + int? lastDataUplinkRate, + String? deviceId, + }) { + return ConnectedDevice( + instancePath: 'Device.Hosts.Host.1.', + macAddress: macAddress, + deviceRole: deviceRole, + hostName: hostName, + friendlyName: friendlyName, + ipAddress: ipAddress, + interface_: interface_, + interfaceType: interfaceType, + isActive: isActive, + signalStrength: signalStrength, + lastDataDownlinkRate: lastDataDownlinkRate, + lastDataUplinkRate: lastDataUplinkRate, + deviceId: deviceId, + ipv4Addresses: const [], + ipv6Addresses: const [], + manufacturer: '', + modelName: '', + operatingSystem: '', + ); + } + + MasterNode buildMasterNode({ + required String deviceId, + String model = 'TestRouter', + }) { + return MasterNode( + deviceId: deviceId, + model: model, + manufacturer: 'Test', + serialNumber: 'SN123', + softwareVersion: '1.0.0', + ); + } + + SlaveNode buildSlaveNode({ + required String deviceId, + String model = 'TestExtender', + BackhaulInfo? backhaul, + }) { + return SlaveNode( + deviceId: deviceId, + model: model, + manufacturer: 'Test', + serialNumber: 'SN456', + softwareVersion: '1.0.0', + backhaul: backhaul ?? const BackhaulInfo(mediaType: 'Wi-Fi'), + ); + } + + // --------------------------------------------------------------------------- + // Tests + // --------------------------------------------------------------------------- + + group('MeshNetworkBuilder.build', () { + test('separates master and slave nodes from clients', () { + final connectedDevices = ConnectedDevices(items: [ + buildConnectedDevice( + macAddress: 'AA:BB:CC:DD:EE:01', + deviceRole: 'master', + hostName: 'Router', + ), + buildConnectedDevice( + macAddress: 'AA:BB:CC:DD:EE:02', + deviceRole: 'slave', + hostName: 'Extender', + ), + buildConnectedDevice( + macAddress: '11:22:33:44:55:01', + deviceRole: 'client', + hostName: 'Phone', + interface_: 'Device.WiFi.Radio.1', + isActive: true, + ), + ]); + + final result = MeshNetworkBuilder.build( + connectedDevices: connectedDevices, + wifiClientMap: {}, + connectionDetailMap: {}, + meshTopology: MeshTopologyInfo.empty, + gatewayName: 'Router', + ); + + expect(result.master.deviceId, 'AA:BB:CC:DD:EE:01'); + expect(result.slaves.length, 1); + expect(result.slaves.first.deviceId, 'AA:BB:CC:DD:EE:02'); + expect(result.allClients.length, 1); + expect(result.allClients.first.mac, '11:22:33:44:55:01'); + }); + + test('assigns clients to master when no mesh topology', () { + final connectedDevices = ConnectedDevices(items: [ + buildConnectedDevice( + macAddress: 'AA:BB:CC:DD:EE:01', + deviceRole: 'master', + hostName: 'Router', + ), + buildConnectedDevice( + macAddress: '11:22:33:44:55:01', + deviceRole: 'client', + hostName: 'Phone', + interface_: 'Device.WiFi.Radio.1', + isActive: true, + ), + ]); + + final result = MeshNetworkBuilder.build( + connectedDevices: connectedDevices, + wifiClientMap: {}, + connectionDetailMap: {}, + meshTopology: MeshTopologyInfo.empty, + gatewayName: 'Router', + ); + + expect(result.master.connectedClients.length, 1); + expect(result.master.connectedClients.first.mac, '11:22:33:44:55:01'); + }); + + test('assigns clients to correct node via clientToNodeMap', () { + final connectedDevices = ConnectedDevices(items: [ + buildConnectedDevice( + macAddress: 'AA:BB:CC:DD:EE:01', + deviceRole: 'master', + hostName: 'Router', + ), + buildConnectedDevice( + macAddress: 'AA:BB:CC:DD:EE:02', + deviceRole: 'slave', + hostName: 'Extender', + ), + buildConnectedDevice( + macAddress: '11:22:33:44:55:01', + deviceRole: 'client', + hostName: 'MasterClient', + interface_: 'Device.WiFi.Radio.1', + isActive: true, + ), + buildConnectedDevice( + macAddress: '11:22:33:44:55:02', + deviceRole: 'client', + hostName: 'SlaveClient', + interface_: 'Device.WiFi.Radio.1', + isActive: true, + ), + ]); + + final meshTopology = MeshTopologyInfo( + nodes: [ + buildMasterNode(deviceId: 'AA:BB:CC:DD:EE:01'), + buildSlaveNode(deviceId: 'AA:BB:CC:DD:EE:02'), + ], + clientToNodeMap: { + '11:22:33:44:55:01': 'AA:BB:CC:DD:EE:01', // on master + '11:22:33:44:55:02': 'AA:BB:CC:DD:EE:02', // on slave + }, + ); + + final result = MeshNetworkBuilder.build( + connectedDevices: connectedDevices, + wifiClientMap: {}, + connectionDetailMap: {}, + meshTopology: meshTopology, + gatewayName: 'Router', + ); + + expect(result.master.connectedClients.length, 1); + expect(result.master.connectedClients.first.hostName, 'MasterClient'); + expect(result.slaves.first.connectedClients.length, 1); + expect( + result.slaves.first.connectedClients.first.hostName, 'SlaveClient'); + }); + + test('uses clientSignalMap as signal fallback for slave clients', () { + final connectedDevices = ConnectedDevices(items: [ + buildConnectedDevice( + macAddress: 'AA:BB:CC:DD:EE:01', + deviceRole: 'master', + ), + buildConnectedDevice( + macAddress: 'AA:BB:CC:DD:EE:02', + deviceRole: 'slave', + ), + buildConnectedDevice( + macAddress: '11:22:33:44:55:01', + deviceRole: 'client', + hostName: 'SlaveClient', + interface_: 'Device.WiFi.Radio.1', + interfaceType: 'Wi-Fi', + isActive: true, + signalStrength: null, // No signal from Hosts + ), + ]); + + final meshTopology = MeshTopologyInfo( + nodes: [ + buildMasterNode(deviceId: 'AA:BB:CC:DD:EE:01'), + buildSlaveNode(deviceId: 'AA:BB:CC:DD:EE:02'), + ], + clientToNodeMap: { + '11:22:33:44:55:01': 'AA:BB:CC:DD:EE:02', + }, + clientSignalMap: { + '11:22:33:44:55:01': -45, // Signal from DataElements + }, + ); + + final result = MeshNetworkBuilder.build( + connectedDevices: connectedDevices, + wifiClientMap: {}, + connectionDetailMap: {}, + meshTopology: meshTopology, + gatewayName: 'Router', + ); + + final client = result.slaves.first.connectedClients.first; + expect(client.signalStrength, -45); + }); + + test('uses clientBandSsidMap as band/SSID fallback for slave clients', () { + final connectedDevices = ConnectedDevices(items: [ + buildConnectedDevice( + macAddress: 'AA:BB:CC:DD:EE:01', + deviceRole: 'master', + ), + buildConnectedDevice( + macAddress: 'AA:BB:CC:DD:EE:02', + deviceRole: 'slave', + ), + buildConnectedDevice( + macAddress: '11:22:33:44:55:01', + deviceRole: 'client', + hostName: 'SlaveClient', + interface_: 'Device.WiFi.Radio.1', + interfaceType: 'Wi-Fi', + isActive: true, + ), + ]); + + final meshTopology = MeshTopologyInfo( + nodes: [ + buildMasterNode(deviceId: 'AA:BB:CC:DD:EE:01'), + buildSlaveNode(deviceId: 'AA:BB:CC:DD:EE:02'), + ], + clientToNodeMap: { + '11:22:33:44:55:01': 'AA:BB:CC:DD:EE:02', + }, + clientBandSsidMap: { + '11:22:33:44:55:01': (band: '5GHz', ssid: 'HomeNetwork'), + }, + ); + + final result = MeshNetworkBuilder.build( + connectedDevices: connectedDevices, + wifiClientMap: {}, + connectionDetailMap: {}, // No connectionDetailMap for slave client + meshTopology: meshTopology, + gatewayName: 'Router', + ); + + final client = result.slaves.first.connectedClients.first; + expect(client.band, '5GHz'); + expect(client.wifi?.ssidName, 'HomeNetwork'); + }); + + test('connectionDetailMap takes precedence over clientBandSsidMap', () { + final connectedDevices = ConnectedDevices(items: [ + buildConnectedDevice( + macAddress: 'AA:BB:CC:DD:EE:01', + deviceRole: 'master', + ), + buildConnectedDevice( + macAddress: '11:22:33:44:55:01', + deviceRole: 'client', + hostName: 'MasterClient', + interface_: 'Device.WiFi.Radio.1', + interfaceType: 'Wi-Fi', + isActive: true, + ), + ]); + + final meshTopology = MeshTopologyInfo( + nodes: [buildMasterNode(deviceId: 'AA:BB:CC:DD:EE:01')], + clientToNodeMap: { + '11:22:33:44:55:01': 'AA:BB:CC:DD:EE:01', + }, + clientBandSsidMap: { + '11:22:33:44:55:01': (band: '2.4GHz', ssid: 'OldSSID'), + }, + ); + + final result = MeshNetworkBuilder.build( + connectedDevices: connectedDevices, + wifiClientMap: {}, + connectionDetailMap: { + '11:22:33:44:55:01': + ClientConnectionDetail(band: '5GHz', ssidName: 'NewSSID'), + }, + meshTopology: meshTopology, + gatewayName: 'Router', + ); + + final client = result.master.connectedClients.first; + expect(client.band, '5GHz'); // From connectionDetailMap + expect(client.wifi?.ssidName, 'NewSSID'); // From connectionDetailMap + }); + + test( + 'empty-string band/SSID in connectionDetailMap falls back to ' + 'clientBandSsidMap', () { + // Regression: ClientConnectionDetail.band is a non-nullable String that + // is '' when AP→SSID→radio resolution fails. An empty string must be + // treated as absent so the DataElements value is used instead. + final connectedDevices = ConnectedDevices(items: [ + buildConnectedDevice( + macAddress: 'AA:BB:CC:DD:EE:01', + deviceRole: 'master', + ), + buildConnectedDevice( + macAddress: '11:22:33:44:55:01', + deviceRole: 'client', + hostName: 'MasterClient', + interface_: 'Device.WiFi.Radio.1', + interfaceType: 'Wi-Fi', + isActive: true, + ), + ]); + + final meshTopology = MeshTopologyInfo( + nodes: [buildMasterNode(deviceId: 'AA:BB:CC:DD:EE:01')], + clientToNodeMap: { + '11:22:33:44:55:01': 'AA:BB:CC:DD:EE:01', + }, + clientBandSsidMap: { + '11:22:33:44:55:01': (band: '5GHz', ssid: 'ResolvedSSID'), + }, + ); + + final result = MeshNetworkBuilder.build( + connectedDevices: connectedDevices, + wifiClientMap: {}, + connectionDetailMap: { + // Present but unresolved → empty strings. + '11:22:33:44:55:01': ClientConnectionDetail(band: '', ssidName: ''), + }, + meshTopology: meshTopology, + gatewayName: 'Router', + ); + + final client = result.master.connectedClients.first; + expect(client.band, '5GHz'); // Fell back to clientBandSsidMap + expect(client.wifi?.ssidName, 'ResolvedSSID'); // Fell back + }); + + test('merges multi-interface devices by hostname', () { + final connectedDevices = ConnectedDevices(items: [ + buildConnectedDevice( + macAddress: 'AA:BB:CC:DD:EE:01', + deviceRole: 'master', + ), + buildConnectedDevice( + macAddress: '11:22:33:44:55:01', + deviceRole: 'client', + hostName: 'Laptop', + interface_: 'Device.WiFi.Radio.1', + interfaceType: 'Wi-Fi', + isActive: true, + ), + buildConnectedDevice( + macAddress: '11:22:33:44:55:02', + deviceRole: 'client', + hostName: 'Laptop', // Same hostname + interface_: 'Device.Ethernet.Interface.1', + interfaceType: 'Ethernet', + isActive: true, + ), + ]); + + final result = MeshNetworkBuilder.build( + connectedDevices: connectedDevices, + wifiClientMap: {}, + connectionDetailMap: {}, + meshTopology: MeshTopologyInfo.empty, + gatewayName: 'Router', + ); + + // Should merge into 1 client with additional interfaces + expect(result.allClients.length, 1); + expect(result.allClients.first.hostName, 'Laptop'); + expect(result.allClients.first.hasMultipleInterfaces, isTrue); + expect(result.allClients.first.additionalInterfaces.length, 1); + }); + + test('patches parentNodeName on clients', () { + final connectedDevices = ConnectedDevices(items: [ + buildConnectedDevice( + macAddress: 'AA:BB:CC:DD:EE:01', + deviceRole: 'master', + friendlyName: 'Living Room Router', + ), + buildConnectedDevice( + macAddress: '11:22:33:44:55:01', + deviceRole: 'client', + hostName: 'Phone', + interface_: 'Device.WiFi.Radio.1', + isActive: true, + ), + ]); + + final result = MeshNetworkBuilder.build( + connectedDevices: connectedDevices, + wifiClientMap: {}, + connectionDetailMap: {}, + meshTopology: MeshTopologyInfo.empty, + gatewayName: 'Router', + ); + + expect( + result.master.connectedClients.first.parentNodeName, + 'Living Room Router', + ); + }); + + test('uses wifiClientMap for signal enrichment on master clients', () { + final connectedDevices = ConnectedDevices(items: [ + buildConnectedDevice( + macAddress: 'AA:BB:CC:DD:EE:01', + deviceRole: 'master', + ), + buildConnectedDevice( + macAddress: '11:22:33:44:55:01', + deviceRole: 'client', + hostName: 'Phone', + interface_: 'Device.WiFi.Radio.1', + interfaceType: 'Wi-Fi', + isActive: true, + signalStrength: null, // No signal from Hosts + ), + ]); + + final wifiClientMap = { + '11:22:33:44:55:01': WifiClientUIModel( + macAddress: '11:22:33:44:55:01', + signalStrength: -50, + noise: -90, + lastDataDownlinkRate: 100000, + lastDataUplinkRate: 50000, + active: true, + ), + }; + + final result = MeshNetworkBuilder.build( + connectedDevices: connectedDevices, + wifiClientMap: wifiClientMap, + connectionDetailMap: {}, + meshTopology: MeshTopologyInfo.empty, + gatewayName: 'Router', + ); + + final client = result.master.connectedClients.first; + expect(client.signalStrength, -50); + expect(client.downlinkRate, 100000); + expect(client.uplinkRate, 50000); + }); + + test('detects WiFi client via interfaceType containing wi-fi', () { + final connectedDevices = ConnectedDevices(items: [ + buildConnectedDevice( + macAddress: 'AA:BB:CC:DD:EE:01', + deviceRole: 'master', + ), + buildConnectedDevice( + macAddress: '11:22:33:44:55:01', + deviceRole: 'client', + hostName: 'Phone', + interface_: '', // Empty interface + interfaceType: 'Wi-Fi', // But interfaceType indicates WiFi + isActive: true, + ), + ]); + + final result = MeshNetworkBuilder.build( + connectedDevices: connectedDevices, + wifiClientMap: {}, + connectionDetailMap: {}, + meshTopology: MeshTopologyInfo.empty, + gatewayName: 'Router', + ); + + expect(result.master.connectedClients.first.isWifi, isTrue); + }); + + test('detects wired client correctly', () { + final connectedDevices = ConnectedDevices(items: [ + buildConnectedDevice( + macAddress: 'AA:BB:CC:DD:EE:01', + deviceRole: 'master', + ), + buildConnectedDevice( + macAddress: '11:22:33:44:55:01', + deviceRole: 'client', + hostName: 'Desktop', + interface_: 'Device.Ethernet.Interface.1', + interfaceType: 'Ethernet', + isActive: true, + ), + ]); + + final result = MeshNetworkBuilder.build( + connectedDevices: connectedDevices, + wifiClientMap: {}, + connectionDetailMap: {}, + meshTopology: MeshTopologyInfo.empty, + gatewayName: 'Router', + ); + + expect(result.master.connectedClients.first.isWifi, isFalse); + expect( + result.master.connectedClients.first.connectionType, + ConnectionType.wired, + ); + }); + + test('handles empty connectedDevices gracefully', () { + final result = MeshNetworkBuilder.build( + connectedDevices: ConnectedDevices(items: []), + wifiClientMap: {}, + connectionDetailMap: {}, + meshTopology: MeshTopologyInfo.empty, + gatewayName: 'Router', + ); + + expect(result.master.deviceId, 'GATEWAY'); + expect(result.slaves, isEmpty); + expect(result.allClients, isEmpty); + }); + + test('uses gatewayName as fallback when no master device found', () { + final result = MeshNetworkBuilder.build( + connectedDevices: ConnectedDevices(items: []), + wifiClientMap: {}, + connectionDetailMap: {}, + meshTopology: MeshTopologyInfo.empty, + gatewayName: 'MyRouter', + ); + + expect(result.master.hostName, 'MyRouter'); + }); + + test('normalizes MAC addresses to uppercase', () { + final connectedDevices = ConnectedDevices(items: [ + buildConnectedDevice( + macAddress: 'aa:bb:cc:dd:ee:01', // lowercase + deviceRole: 'master', + ), + buildConnectedDevice( + macAddress: '11:22:33:44:55:01', // lowercase + deviceRole: 'client', + hostName: 'Phone', + interface_: 'Device.WiFi.Radio.1', + isActive: true, + ), + ]); + + final result = MeshNetworkBuilder.build( + connectedDevices: connectedDevices, + wifiClientMap: {}, + connectionDetailMap: {}, + meshTopology: MeshTopologyInfo.empty, + gatewayName: 'Router', + ); + + expect(result.master.deviceId, 'AA:BB:CC:DD:EE:01'); + expect(result.allClients.first.mac, '11:22:33:44:55:01'); + }); + }); +} diff --git a/test/page/_shared/utils/mesh_topology_builder_test.dart b/test/page/_shared/utils/mesh_topology_builder_test.dart index 0f7c4b866..aa2e942ed 100644 --- a/test/page/_shared/utils/mesh_topology_builder_test.dart +++ b/test/page/_shared/utils/mesh_topology_builder_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:privacy_gui/generated/data_elements_network.g.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; import 'package:privacy_gui/page/_shared/utils/mesh_topology_builder.dart'; void main() { @@ -146,8 +147,9 @@ void main() { final result = MeshTopologyBuilder.build(network); + final slave = result.nodes[0] as SlaveNode; // RCPI = 180 → RSSI = (180/2) - 110 = -20 dBm - expect(result.nodes[0].backhaulSignalStrength, -20); + expect(slave.backhaul.signalStrength, -20); }); test('includes backhaul uplink rate when available', () { @@ -155,7 +157,8 @@ void main() { final result = MeshTopologyBuilder.build(network); - expect(result.nodes[0].backhaulUplinkRate, 500000); + final slave = result.nodes[0] as SlaveNode; + expect(slave.backhaul.uplinkRate, 500000); }); test('excludes backhaul stats when includeBackhaulStats is false', () { @@ -164,11 +167,12 @@ void main() { final result = MeshTopologyBuilder.build(network, includeBackhaulStats: false); - expect(result.nodes[0].backhaulSignalStrength, isNull); - expect(result.nodes[0].backhaulUplinkRate, isNull); + final slave = result.nodes[0] as SlaveNode; + expect(slave.backhaul.signalStrength, isNull); + expect(slave.backhaul.uplinkRate, isNull); // Other backhaul fields are still included - expect(result.nodes[0].backhaulMediaType, 'IEEE 802.11ax'); - expect(result.nodes[0].backhaulPhyRate, 1200); + expect(slave.backhaul.mediaType, 'IEEE 802.11ax'); + expect(slave.backhaul.phyRate, 1200); }); test('normalizes MAC addresses to uppercase', () { @@ -279,10 +283,10 @@ void main() { final result = MeshTopologyBuilder.build(network); - expect(result.nodes[0].instancePath, - 'Device.WiFi.DataElements.Network.Device.2.'); - expect(result.nodes[0].backhaulAlId, 'AA:BB:CC:DD:EE:01'); - expect(result.nodes[0].backhaulMacAddress, 'AA:BB:CC:DD:EE:02'); + final slave = result.nodes[0] as SlaveNode; + expect(slave.instancePath, 'Device.WiFi.DataElements.Network.Device.2.'); + expect(slave.backhaul.backhaulAlId, 'AA:BB:CC:DD:EE:01'); + expect(slave.backhaul.backhaulMacAddress, 'AA:BB:CC:DD:EE:02'); }); test('includes backhaulLinkType', () { @@ -290,7 +294,8 @@ void main() { final result = MeshTopologyBuilder.build(network); - expect(result.nodes[0].backhaulLinkType, 'Wi-Fi'); + final slave = result.nodes[0] as SlaveNode; + expect(slave.backhaul.linkType, 'Wi-Fi'); }); test('includes backhaulDownlinkRate', () { @@ -298,7 +303,8 @@ void main() { final result = MeshTopologyBuilder.build(network); - expect(result.nodes[0].backhaulDownlinkRate, 600000); + final slave = result.nodes[0] as SlaveNode; + expect(slave.backhaul.downlinkRate, 600000); }); test('includes backhaulParentDeviceId', () { @@ -306,7 +312,8 @@ void main() { final result = MeshTopologyBuilder.build(network); - expect(result.nodes[0].backhaulParentDeviceId, 'AA:BB:CC:DD:EE:01'); + final slave = result.nodes[0] as SlaveNode; + expect(slave.backhaul.parentNodeId, 'AA:BB:CC:DD:EE:01'); }); test('includes backhaulParentBssid', () { @@ -314,7 +321,8 @@ void main() { final result = MeshTopologyBuilder.build(network); - expect(result.nodes[0].backhaulParentBssid, 'AA:BB:CC:DD:EE:01'); + final slave = result.nodes[0] as SlaveNode; + expect(slave.backhaul.parentBssid, 'AA:BB:CC:DD:EE:01'); }); test('excludes backhaulDownlinkRate when includeBackhaulStats is false', @@ -324,22 +332,99 @@ void main() { final result = MeshTopologyBuilder.build(network, includeBackhaulStats: false); - expect(result.nodes[0].backhaulDownlinkRate, isNull); + final slave = result.nodes[0] as SlaveNode; + expect(slave.backhaul.downlinkRate, isNull); // Non-stats fields are still included - expect(result.nodes[0].backhaulLinkType, 'Wi-Fi'); - expect(result.nodes[0].backhaulParentDeviceId, 'AA:BB:CC:DD:EE:01'); + expect(slave.backhaul.linkType, 'Wi-Fi'); + expect(slave.backhaul.parentNodeId, 'AA:BB:CC:DD:EE:01'); }); - test('returns null for empty new backhaul fields', () { + test('master node has no backhaul fields', () { final network = DataElementsNetwork(items: [masterNode]); final result = MeshTopologyBuilder.build(network); - expect(result.nodes[0].backhaulLinkType, isNull); - expect(result.nodes[0].backhaulDownlinkRate, isNull); - expect(result.nodes[0].backhaulParentDeviceId, isNull); - expect(result.nodes[0].backhaulParentBssid, isNull); - expect(result.nodes[0].lastContactTime, isNull); + expect(result.nodes[0], isA()); + expect(result.nodes[0].isMaster, isTrue); + }); + + test('populates clientBandSsidMap when bssidToBandMap is provided', () { + final nodeWithClient = MeshNode( + instancePath: 'Device.WiFi.DataElements.Network.Device.1.', + id: 'AA:BB:CC:DD:EE:01', + manufacturerModel: 'TestRouter', + manufacturer: 'Test', + serialNumber: 'SN123', + softwareVersion: '1.0.0', + backhaulAlId: '', + backhaulMacAddress: '', + backhaulMediaType: '', + backhaulPhyRate: 0, + multiApLastContactTime: '', + multiApAssocIEEE1905DeviceRef: '', + multiApEasyMeshAgentOperationMode: '', + backhaulBackhaulDeviceId: '', + backhaulBackhaulMacAddress: '', + backhaulLinkType: '', + backhaulMacAddressMultiAp: '', + backhaulStatsLastDataDownlinkRate: 0, + backhaulStatsPacketsSent: 0, + backhaulStatsPacketsReceived: 0, + backhaulStatsErrorsSent: 0, + backhaulStatsErrorsReceived: 0, + backhaulStatsTimeStamp: '', + backhaulStatsLastDataUplinkRate: 0, + backhaulStatsSignalStrength: 0, + radios: [ + MeshRadio( + instancePath: 'Device.WiFi.DataElements.Network.Device.1.Radio.1.', + bssList: [ + MeshBss( + instancePath: + 'Device.WiFi.DataElements.Network.Device.1.Radio.1.BSS.1.', + bssid: '11:22:33:44:55:01', + ssid: 'TestNetwork', + stations: [ + MeshStation( + instancePath: + 'Device.WiFi.DataElements.Network.Device.1.Radio.1.BSS.1.STA.1.', + macAddress: 'aa:bb:cc:dd:ee:ff', + signalStrength: 140, + ), + ], + ), + ], + ), + ], + ); + + final network = DataElementsNetwork(items: [nodeWithClient]); + final bssidToBandMap = {'11:22:33:44:55:01': '5GHz'}; + + final result = MeshTopologyBuilder.build( + network, + bssidToBandMap: bssidToBandMap, + ); + + expect(result.clientBandSsidMap, isNotEmpty); + expect(result.clientBandSsidMap['AA:BB:CC:DD:EE:FF']?.band, '5GHz'); + expect( + result.clientBandSsidMap['AA:BB:CC:DD:EE:FF']?.ssid, 'TestNetwork'); + }); + + test('clientBandSsidMap has SSID but empty band without bssidToBandMap', + () { + final network = DataElementsNetwork(items: [masterNode]); + + final result = MeshTopologyBuilder.build(network); + + // Has SSID from BSS but no band since no bssidToBandMap provided + expect(result.clientBandSsidMap, isNotEmpty); + // Band should be empty string + for (final entry in result.clientBandSsidMap.values) { + expect(entry.band, isEmpty); + expect(entry.ssid, 'HomeNetwork'); + } }); }); } diff --git a/test/page/admin/providers/usp_admin_notifier_test.dart b/test/page/admin/providers/usp_admin_notifier_test.dart index 301c50776..45d343cc5 100644 --- a/test/page/admin/providers/usp_admin_notifier_test.dart +++ b/test/page/admin/providers/usp_admin_notifier_test.dart @@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:privacy_gui/core/errors/service_error.dart'; +import 'package:privacy_gui/core/usp/providers/usp_auth_coordinator.dart'; import 'package:privacy_gui/core/usp/providers/usp_mutation_lock.dart'; import 'package:privacy_gui/core/usp/providers/usp_client_provider.dart'; import 'package:privacy_gui/core/usp/services/usp_client.dart'; @@ -10,11 +11,28 @@ import 'package:privacy_gui/page/admin/models/admin_ui_models.dart'; import 'package:privacy_gui/page/admin/providers/time_data_provider.dart'; import 'package:privacy_gui/page/admin/providers/usp_admin_notifier.dart'; import 'package:privacy_gui/page/admin/services/usp_admin_service.dart'; +import 'package:privacy_gui/providers/auth/auth_provider.dart'; class MockUspClient extends Mock implements UspClient {} class MockUspAdminService extends Mock implements UspAdminService {} +class MockUspAuthCoordinator extends Mock implements UspAuthCoordinator {} + +class MockAuthNotifier extends AsyncNotifier + with Mock + implements AuthNotifier { + int logoutCallCount = 0; + + @override + Future build() async => AuthState(loginType: LoginType.local); + + @override + Future logout() async { + logoutCallCount++; + } +} + /// Test-only time data notifier returning canned data. class _TestTimeDataNotifier extends TimeDataNotifier { final TimeData _data; @@ -27,6 +45,8 @@ class _TestTimeDataNotifier extends TimeDataNotifier { void main() { late MockUspClient mockUsp; late MockUspAdminService mockAdminService; + late MockUspAuthCoordinator mockAuthCoordinator; + late MockAuthNotifier mockAuthNotifier; late _TestTimeDataNotifier testTimeNotifier; const testAdmin = AdminUserUIModel( @@ -49,9 +69,13 @@ void main() { setUp(() { mockUsp = MockUspClient(); mockAdminService = MockUspAdminService(); + mockAuthCoordinator = MockUspAuthCoordinator(); + mockAuthNotifier = MockAuthNotifier(); testTimeNotifier = _TestTimeDataNotifier(testTimeData); when(() => mockUsp.isAuthenticated).thenReturn(true); + when(() => mockAuthCoordinator.reloginWithNewPassword(any())) + .thenAnswer((_) async {}); }); ProviderContainer createContainer() { @@ -61,6 +85,8 @@ void main() { uspAdminServiceProvider.overrideWithValue(mockAdminService), uspMutationLockProvider.overrideWithValue(UspMutationLock()), timeDataProvider.overrideWith(() => testTimeNotifier), + uspAuthCoordinatorProvider.overrideWithValue(mockAuthCoordinator), + authProvider.overrideWith(() => mockAuthNotifier), ], ); return container; @@ -174,7 +200,8 @@ void main() { // Error handling // ----------------------------------------------------------------------- - test('setAdminPassword rethrows ServiceError', () async { + test('setAdminPassword rethrows ServiceError when updatePassword fails', + () async { when(() => mockAdminService.fetchAdmin()) .thenAnswer((_) async => testAdmin); when(() => mockAdminService.updatePassword( @@ -194,6 +221,38 @@ void main() { container.dispose(); }); + test( + 'setAdminPassword triggers logout when relogin fails after password change', + () async { + when(() => mockAdminService.fetchAdmin()) + .thenAnswer((_) async => testAdmin); + when(() => mockAdminService.updatePassword( + instancePath: any(named: 'instancePath'), + newPassword: any(named: 'newPassword'), + )).thenAnswer((_) async {}); + when(() => mockAuthCoordinator.reloginWithNewPassword(any())) + .thenThrow(const NetworkError(detail: 'connection lost')); + + final container = createContainer(); + await container.read(uspAdminProvider.future); + + // Should not throw — password was changed successfully + await container + .read(uspAdminProvider.notifier) + .setAdminPassword('new123'); + + // Verify updatePassword was called (password changed) + verify(() => mockAdminService.updatePassword( + instancePath: 'Device.Users.User.1.', + newPassword: 'new123', + )).called(1); + + // Verify logout was triggered due to relogin failure + expect(mockAuthNotifier.logoutCallCount, 1); + + container.dispose(); + }); + test('reboot rethrows ServiceError', () async { when(() => mockAdminService.fetchAdmin()) .thenAnswer((_) async => testAdmin); diff --git a/test/page/dashboard/mascot/health/dimensions/devices_dimension_test.dart b/test/page/dashboard/mascot/health/dimensions/devices_dimension_test.dart index 606be67a4..db2db0c7d 100644 --- a/test/page/dashboard/mascot/health/dimensions/devices_dimension_test.dart +++ b/test/page/dashboard/mascot/health/dimensions/devices_dimension_test.dart @@ -2,7 +2,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:privacy_gui/l10n/gen/app_localizations.dart'; import 'package:privacy_gui/core/usp/providers/sse_invalidation_provider.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_network.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; import 'package:privacy_gui/page/dashboard/mascot/health/dimensions/devices_dimension.dart'; import 'package:privacy_gui/page/dashboard/mascot/health/health_dimension.dart'; import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; @@ -15,18 +17,28 @@ void main() { dimension = DevicesHealthDimension(); }); - DeviceUIModel createDevice({ + ClientDevice createDevice({ required String mac, required bool isActive, - String? deviceRole, }) { - return DeviceUIModel( + return ClientDevice( mac: mac, ip: '192.168.1.10', hostName: 'device-$mac', isActive: isActive, - isWifi: true, - deviceRole: deviceRole, + connectionType: ConnectionType.wifi, + ); + } + + DevicesData createDevicesData(List clients) { + return DevicesData( + meshNetwork: MeshNetwork( + master: MasterNode( + deviceId: 'GATEWAY', + model: 'MR7500', + connectedClients: clients, + ), + ), ); } @@ -41,7 +53,7 @@ void main() { test('returns 100 when no devices', () { final context = HealthEvaluationContext( - devices: const DevicesData(deviceModels: []), + devices: createDevicesData([]), ); final score = dimension.evaluate(context); @@ -51,12 +63,10 @@ void main() { test('returns 100 when all devices online', () { final context = HealthEvaluationContext( - devices: DevicesData( - deviceModels: [ - createDevice(mac: 'AA:BB:CC:DD:EE:01', isActive: true), - createDevice(mac: 'AA:BB:CC:DD:EE:02', isActive: true), - ], - ), + devices: createDevicesData([ + createDevice(mac: 'AA:BB:CC:DD:EE:01', isActive: true), + createDevice(mac: 'AA:BB:CC:DD:EE:02', isActive: true), + ]), ); final score = dimension.evaluate(context); @@ -66,15 +76,13 @@ void main() { test('returns 80 when > 80% online', () { final context = HealthEvaluationContext( - devices: DevicesData( - deviceModels: [ - createDevice(mac: 'AA:BB:CC:DD:EE:01', isActive: true), - createDevice(mac: 'AA:BB:CC:DD:EE:02', isActive: true), - createDevice(mac: 'AA:BB:CC:DD:EE:03', isActive: true), - createDevice(mac: 'AA:BB:CC:DD:EE:04', isActive: true), - createDevice(mac: 'AA:BB:CC:DD:EE:05', isActive: false), // 80% - ], - ), + devices: createDevicesData([ + createDevice(mac: 'AA:BB:CC:DD:EE:01', isActive: true), + createDevice(mac: 'AA:BB:CC:DD:EE:02', isActive: true), + createDevice(mac: 'AA:BB:CC:DD:EE:03', isActive: true), + createDevice(mac: 'AA:BB:CC:DD:EE:04', isActive: true), + createDevice(mac: 'AA:BB:CC:DD:EE:05', isActive: false), // 80% + ]), ); final score = dimension.evaluate(context); @@ -84,14 +92,12 @@ void main() { test('returns 60 when > 50% online', () { final context = HealthEvaluationContext( - devices: DevicesData( - deviceModels: [ - createDevice(mac: 'AA:BB:CC:DD:EE:01', isActive: true), - createDevice(mac: 'AA:BB:CC:DD:EE:02', isActive: true), - createDevice(mac: 'AA:BB:CC:DD:EE:03', isActive: false), - createDevice(mac: 'AA:BB:CC:DD:EE:04', isActive: false), // 50% - ], - ), + devices: createDevicesData([ + createDevice(mac: 'AA:BB:CC:DD:EE:01', isActive: true), + createDevice(mac: 'AA:BB:CC:DD:EE:02', isActive: true), + createDevice(mac: 'AA:BB:CC:DD:EE:03', isActive: false), + createDevice(mac: 'AA:BB:CC:DD:EE:04', isActive: false), // 50% + ]), ); final score = dimension.evaluate(context); @@ -100,20 +106,12 @@ void main() { }); test('excludes mesh nodes from client count', () { + // Mesh nodes are tracked separately in MeshNetwork.allNodes, + // so we only need to pass client devices to the master's connectedClients final context = HealthEvaluationContext( - devices: DevicesData( - deviceModels: [ - createDevice(mac: 'AA:BB:CC:DD:EE:01', isActive: true), - createDevice( - mac: 'AA:BB:CC:DD:EE:02', - isActive: true, - deviceRole: 'master'), - createDevice( - mac: 'AA:BB:CC:DD:EE:03', - isActive: true, - deviceRole: 'slave'), - ], - ), + devices: createDevicesData([ + createDevice(mac: 'AA:BB:CC:DD:EE:01', isActive: true), + ]), ); final score = dimension.evaluate(context); @@ -125,12 +123,10 @@ void main() { group('getSummary', () { test('returns All Online when all devices active', () { final context = HealthEvaluationContext( - devices: DevicesData( - deviceModels: [ - createDevice(mac: 'AA:BB:CC:DD:EE:01', isActive: true), - createDevice(mac: 'AA:BB:CC:DD:EE:02', isActive: true), - ], - ), + devices: createDevicesData([ + createDevice(mac: 'AA:BB:CC:DD:EE:01', isActive: true), + createDevice(mac: 'AA:BB:CC:DD:EE:02', isActive: true), + ]), ); final summary = dimension.getSummary(context); @@ -141,7 +137,7 @@ void main() { test('returns No devices when empty', () { final context = HealthEvaluationContext( - devices: const DevicesData(deviceModels: []), + devices: createDevicesData([]), ); final summary = dimension.getSummary(context); diff --git a/test/page/dashboard/models/usp_dashboard_preset_test.dart b/test/page/dashboard/models/usp_dashboard_preset_test.dart index f538a6e25..f9c2b8e1a 100644 --- a/test/page/dashboard/models/usp_dashboard_preset_test.dart +++ b/test/page/dashboard/models/usp_dashboard_preset_test.dart @@ -57,8 +57,8 @@ void main() { expect(UspDashboardPreset.standard.cardIds.length, 12); }); - test('professional has 17 cards (all)', () { - expect(UspDashboardPreset.professional.cardIds.length, 17); + test('professional has 18 cards (all)', () { + expect(UspDashboardPreset.professional.cardIds.length, 18); }); test('monitoring has 8 cards', () { diff --git a/test/page/dashboard/providers/dashboard_domain_ready_provider_test.dart b/test/page/dashboard/providers/dashboard_domain_ready_provider_test.dart index fde41c3e8..9ff3e4dba 100644 --- a/test/page/dashboard/providers/dashboard_domain_ready_provider_test.dart +++ b/test/page/dashboard/providers/dashboard_domain_ready_provider_test.dart @@ -1,6 +1,8 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_network.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; import 'package:privacy_gui/page/_shared/models/system_info_ui_model.dart'; import 'package:privacy_gui/page/admin/providers/system_info_data_provider.dart'; import 'package:privacy_gui/page/dashboard/providers/dashboard_domain_ready_provider.dart'; @@ -37,7 +39,11 @@ class _FailSystemInfoNotifier extends SystemInfoDataNotifier { class _OkDevicesNotifier extends DevicesDataNotifier { @override - Future build() async => DevicesData(); + Future build() async => DevicesData( + meshNetwork: MeshNetwork( + master: MasterNode(deviceId: 'GATEWAY', model: 'TestRouter'), + ), + ); } class _FailDevicesNotifier extends DevicesDataNotifier { @@ -61,7 +67,11 @@ class _SlowDevicesNotifier extends DevicesDataNotifier { @override Future build() async { await _gate.future; - return DevicesData(); + return DevicesData( + meshNetwork: MeshNetwork( + master: MasterNode(deviceId: 'GATEWAY', model: 'TestRouter'), + ), + ); } } diff --git a/test/page/dashboard/providers/dashboard_edit_mode_provider_test.dart b/test/page/dashboard/providers/dashboard_edit_mode_provider_test.dart new file mode 100644 index 000000000..149202c52 --- /dev/null +++ b/test/page/dashboard/providers/dashboard_edit_mode_provider_test.dart @@ -0,0 +1,230 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacy_gui/page/dashboard/models/usp_layout_preferences.dart'; +import 'package:privacy_gui/page/dashboard/providers/dashboard_edit_mode_provider.dart'; +import 'package:privacy_gui/page/dashboard/providers/usp_layout_controller.dart'; +import 'package:privacy_gui/page/dashboard/providers/usp_layout_preferences_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +Future pumpAsync() async { + await Future.delayed(const Duration(milliseconds: 100)); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + Future createContainer({ + Map initialValues = const {}, + }) async { + SharedPreferences.setMockInitialValues(initialValues); + final container = ProviderContainer(); + container.read(uspSliverDashboardControllerProvider); + await container.read(uspLayoutPreferencesProvider.notifier).initialized; + await pumpAsync(); + return container; + } + + group('DashboardEditState', () { + test('default state has isEditing=false and null snapshots', () { + const state = DashboardEditState(); + expect(state.isEditing, isFalse); + expect(state.layoutSnapshot, isNull); + expect(state.prefsSnapshot, isNull); + }); + + test('copyWith updates fields correctly', () { + const state = DashboardEditState(); + final updated = state.copyWith( + isEditing: true, + layoutSnapshot: [ + {'id': 'test'} + ], + prefsSnapshot: const UspLayoutPreferences(useCustomLayout: false), + ); + + expect(updated.isEditing, isTrue); + expect(updated.layoutSnapshot, hasLength(1)); + expect(updated.prefsSnapshot?.useCustomLayout, isFalse); + }); + + test('copyWith with clearSnapshots clears snapshots', () { + final state = DashboardEditState( + isEditing: true, + layoutSnapshot: [ + {'id': 'test'} + ], + prefsSnapshot: const UspLayoutPreferences(), + ); + final cleared = state.copyWith(clearSnapshots: true); + + expect(cleared.layoutSnapshot, isNull); + expect(cleared.prefsSnapshot, isNull); + }); + }); + + group('DashboardEditModeNotifier', () { + test('initial state is not editing', () async { + final container = await createContainer(); + addTearDown(container.dispose); + + final state = container.read(dashboardEditModeProvider); + expect(state.isEditing, isFalse); + expect(state.layoutSnapshot, isNull); + expect(state.prefsSnapshot, isNull); + }); + + test('enterEditMode sets isEditing=true and captures snapshots', () async { + final container = await createContainer(); + addTearDown(container.dispose); + + await container.read(dashboardEditModeProvider.notifier).enterEditMode(); + + final state = container.read(dashboardEditModeProvider); + expect(state.isEditing, isTrue); + expect(state.layoutSnapshot, isNotNull); + expect(state.layoutSnapshot, isNotEmpty); + expect(state.prefsSnapshot, isNotNull); + }); + + test('commitEditMode clears state without reverting', () async { + final container = await createContainer(); + addTearDown(container.dispose); + + await container.read(dashboardEditModeProvider.notifier).enterEditMode(); + final snapshotBeforeExit = + container.read(dashboardEditModeProvider).layoutSnapshot; + expect(snapshotBeforeExit, isNotNull); + + await container.read(dashboardEditModeProvider.notifier).commitEditMode(); + + final state = container.read(dashboardEditModeProvider); + expect(state.isEditing, isFalse); + expect(state.layoutSnapshot, isNull); + expect(state.prefsSnapshot, isNull); + }); + + test('commitEditMode preserves layout changes applied during edit', + () async { + // Regression for #1089 (PeterJhong): committing must NOT revert changes + // that were applied during edit mode (e.g. reset / preset change from the + // settings panel). Previously the settings path called the revert branch + // and undid the just-applied change. + final container = await createContainer(); + addTearDown(container.dispose); + + await container.read(dashboardEditModeProvider.notifier).enterEditMode(); + + final controller = container.read(uspSliverDashboardControllerProvider); + final originalCount = controller.exportLayout().length; + controller.removeItems(['stats_panel']); + final afterRemove = controller.exportLayout().length; + expect(afterRemove, lessThan(originalCount)); + + await container.read(dashboardEditModeProvider.notifier).commitEditMode(); + + // Change is kept, not reverted back to originalCount. + final finalCount = container + .read(uspSliverDashboardControllerProvider) + .exportLayout() + .length; + expect(finalCount, equals(afterRemove)); + }); + + test('cancelEditMode reverts layout to snapshot', () async { + final container = await createContainer(); + addTearDown(container.dispose); + + await container.read(dashboardEditModeProvider.notifier).enterEditMode(); + + final originalLayout = container + .read(uspSliverDashboardControllerProvider) + .exportLayout() + .length; + + final controller = container.read(uspSliverDashboardControllerProvider); + controller.removeItems(['stats_panel']); + final afterRemove = controller.exportLayout().length; + expect(afterRemove, lessThan(originalLayout)); + + await container.read(dashboardEditModeProvider.notifier).cancelEditMode(); + + final restoredLayout = + container.read(uspSliverDashboardControllerProvider).exportLayout(); + expect(restoredLayout.length, equals(originalLayout)); + + final state = container.read(dashboardEditModeProvider); + expect(state.isEditing, isFalse); + expect(state.layoutSnapshot, isNull); + expect(state.prefsSnapshot, isNull); + }); + + test( + 're-entrant enterEditMode does not overwrite the original snapshot ' + '(W-1)', () async { + final container = await createContainer(); + addTearDown(container.dispose); + + final notifier = container.read(dashboardEditModeProvider.notifier); + await notifier.enterEditMode(); + + final controller = container.read(uspSliverDashboardControllerProvider); + final originalCount = controller.exportLayout().length; + + // Modify the grid, then re-enter (simulating a double-tap / gesture race). + controller.removeItems(['stats_panel']); + expect(controller.exportLayout().length, lessThan(originalCount)); + + // Re-entrant call must be a no-op — it must NOT re-capture the modified + // grid as the new baseline. + await notifier.enterEditMode(); + + // Cancel should restore the TRUE pre-edit baseline, not the mid-edit state. + await notifier.cancelEditMode(); + final restoredCount = container + .read(uspSliverDashboardControllerProvider) + .exportLayout() + .length; + expect(restoredCount, equals(originalCount)); + }); + + test( + 'cancel during enterEditMode async gap leaves controller not stuck ' + '(W-2)', () async { + final container = await createContainer(); + addTearDown(container.dispose); + + final notifier = container.read(dashboardEditModeProvider.notifier); + + // Start entering edit mode but do NOT await — isEditing is claimed + // synchronously before the internal await. + final enterFuture = notifier.enterEditMode(); + expect(container.read(dashboardEditModeProvider).isEditing, isTrue); + + // A navigation-away in the async gap cancels edit mode. + await notifier.cancelEditMode(); + expect(container.read(dashboardEditModeProvider).isEditing, isFalse); + + // enterEditMode resumes after its await; it must observe the cancel and + // bail out instead of stranding the controller in edit mode. + await enterFuture; + expect(container.read(dashboardEditModeProvider).isEditing, isFalse); + }); + + test('multiple enter/commit cycles work correctly', () async { + final container = await createContainer(); + addTearDown(container.dispose); + + for (int i = 0; i < 3; i++) { + await container + .read(dashboardEditModeProvider.notifier) + .enterEditMode(); + expect(container.read(dashboardEditModeProvider).isEditing, isTrue); + + await container + .read(dashboardEditModeProvider.notifier) + .commitEditMode(); + expect(container.read(dashboardEditModeProvider).isEditing, isFalse); + } + }); + }); +} diff --git a/test/page/dashboard/providers/usp_layout_controller_test.dart b/test/page/dashboard/providers/usp_layout_controller_test.dart index 4f8042417..f23ad6253 100644 --- a/test/page/dashboard/providers/usp_layout_controller_test.dart +++ b/test/page/dashboard/providers/usp_layout_controller_test.dart @@ -583,7 +583,7 @@ void main() { expect(layout.length, 8); }); - test('professional → 17 items', () async { + test('professional → 18 items', () async { final container = await createInitializedContainer(); addTearDown(container.dispose); @@ -593,7 +593,7 @@ void main() { final layout = container.read(uspSliverDashboardControllerProvider).exportLayout(); - expect(layout.length, 17); + expect(layout.length, 18); }); test('saves preset layout to prefs', () async { diff --git a/test/page/dashboard/views/dialogs/wifi_channel_dialog_test.dart b/test/page/dashboard/views/dialogs/wifi_channel_dialog_test.dart new file mode 100644 index 000000000..63df54b61 --- /dev/null +++ b/test/page/dashboard/views/dialogs/wifi_channel_dialog_test.dart @@ -0,0 +1,433 @@ +@Tags(['ui']) +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ui_kit_library/ui_kit.dart'; +import 'package:privacy_gui/l10n/gen/app_localizations.dart'; +import 'package:privacy_gui/page/_shared/models/wifi_radio_ui_model.dart'; +import 'package:privacy_gui/page/dashboard/views/dialogs/wifi_channel_dialog.dart'; + +final _testTheme = AppTheme.create( + brightness: Brightness.light, + seedColor: Colors.blue, + designThemeBuilder: (c) => CustomDesignTheme.fromJson({ + 'style': 'flat', + }), +); + +WifiRadioUIModel _radio({ + String band = '5GHz', + int channel = 36, + bool autoChannelEnable = false, + bool isDfsEnabled = true, + List possibleChannels = const [36, 40, 44, 48, 52, 149], +}) { + return WifiRadioUIModel( + instancePath: 'Device.WiFi.Radio.1.', + band: band, + enable: true, + transmitPower: 100, + maxBitRate: 1200, + channel: channel, + autoChannelEnable: autoChannelEnable, + channelBandwidth: '80MHz', + supportedStandards: 'ax', + possibleChannels: possibleChannels, + isDfsEnabled: isDfsEnabled, + ); +} + +/// White-box widget tests for [WifiChannelDialog]. +void main() { + Widget host(WifiRadioUIModel radio, + void Function(({int channel, bool autoChannel})?) onResult) { + return MaterialApp( + theme: _testTheme, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Builder( + builder: (context) => Center( + child: ElevatedButton( + onPressed: () async { + final r = await showDialog<({int channel, bool autoChannel})>( + context: context, + builder: (_) => WifiChannelDialog(radio: radio), + ); + onResult(r); + }, + child: const Text('open'), + ), + ), + ), + ), + ); + } + + Future openDialog(WidgetTester tester) async { + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + } + + group('WifiChannelDialog', () { + testWidgets('AC1: renders an AppDropdown, not an AppTextField', (t) async { + await t.pumpWidget(host(_radio(), (_) {})); + await openDialog(t); + + expect(find.byType(AppDropdown), findsOneWidget); + expect(find.byType(AppTextField), findsNothing); + }); + + testWidgets('AC4: selecting Auto when already auto is a no-op (null)', + (t) async { + ({int channel, bool autoChannel})? captured; + var called = false; + await t.pumpWidget(host( + _radio(channel: 36, autoChannelEnable: true), + (r) { + captured = r; + called = true; + }, + )); + await openDialog(t); + + await t.tap(find.text('Apply')); + await t.pumpAndSettle(); + + expect(called, isTrue); + expect(captured, isNull); + }); + + testWidgets( + 'AC3/AC4: turning Auto OFF then Apply returns the stored manual channel', + (t) async { + ({int channel, bool autoChannel})? captured; + await t.pumpWidget(host( + _radio(channel: 44, autoChannelEnable: true), + (r) => captured = r, + )); + await openDialog(t); + + // Auto switch is ON initially; toggle it OFF. + await t.tap(find.byType(AppSwitch)); + await t.pumpAndSettle(); + + await t.tap(find.text('Apply')); + await t.pumpAndSettle(); + + expect(captured, isNotNull); + expect(captured!.autoChannel, isFalse); + expect(captured!.channel, 44); + }); + + testWidgets( + 'AC4: switching a manual channel to Auto returns autoChannel:true', + (t) async { + ({int channel, bool autoChannel})? captured; + await t.pumpWidget(host( + _radio(channel: 44, autoChannelEnable: false), + (r) => captured = r, + )); + await openDialog(t); + + // Auto switch is OFF initially; toggle it ON. + await t.tap(find.byType(AppSwitch)); + await t.pumpAndSettle(); + + await t.tap(find.text('Apply')); + await t.pumpAndSettle(); + + expect(captured, isNotNull); + expect(captured!.autoChannel, isTrue); + }); + + testWidgets( + 'AC5: stored channel not in possibleChannels displays as Auto, and ' + 'confirming without moving is a no-op', (t) async { + ({int channel, bool autoChannel})? captured; + var called = false; + await t.pumpWidget(host( + // channel 165 is not in the possibleChannels list. + _radio( + channel: 165, + autoChannelEnable: false, + possibleChannels: const [36, 40, 44]), + (r) { + captured = r; + called = true; + }, + )); + await openDialog(t); + + // The switch should reflect Auto (ghost value suppressed). + final sw = t.widget(find.byType(AppSwitch)); + expect(sw.value, isTrue); + + await t.tap(find.text('Apply')); + await t.pumpAndSettle(); + + // The dialog opened on Auto because 165 is unselectable; the user did not + // move the selection, so Apply must NOT rewrite the radio to Auto. + expect(called, isTrue); + expect(captured, isNull); + }); + + testWidgets( + 'AC6: empty possibleChannels locks to Auto and shows the no-options text', + (t) async { + ({int channel, bool autoChannel})? captured; + await t.pumpWidget(host( + _radio( + channel: 36, autoChannelEnable: true, possibleChannels: const []), + (r) => captured = r, + )); + await openDialog(t); + + expect(find.text('No manual channels available for this band.'), + findsOneWidget); + + // Switch is disabled (no manual channels to switch to). + final sw = t.widget(find.byType(AppSwitch)); + expect(sw.onChanged, isNull); + + // Dropdown is disabled. + final dd = t.widget>(find.byType(AppDropdown)); + expect(dd.onChanged, isNull); + + // Apply is still valid (Auto, unchanged here -> no-op null). + await t.tap(find.text('Apply')); + await t.pumpAndSettle(); + expect(captured, isNull); + }); + + testWidgets('AC3: dropdown disabled while Auto switch is ON', (t) async { + await t.pumpWidget(host( + _radio(channel: 36, autoChannelEnable: true), + (_) {}, + )); + await openDialog(t); + + final dd = t.widget>(find.byType(AppDropdown)); + expect(dd.onChanged, isNull); + }); + + testWidgets('AC3: dropdown enabled while Auto switch is OFF', (t) async { + await t.pumpWidget(host( + _radio(channel: 36, autoChannelEnable: false), + (_) {}, + )); + await openDialog(t); + + final dd = t.widget>(find.byType(AppDropdown)); + expect(dd.onChanged, isNotNull); + }); + + testWidgets('AC9: 5GHz DFS channel is annotated with the DFS suffix', + (t) async { + await t.pumpWidget(host( + _radio( + band: '5GHz', + channel: 52, + autoChannelEnable: false, + possibleChannels: const [36, 52]), + (_) {}, + )); + await openDialog(t); + + final dd = t.widget>(find.byType(AppDropdown)); + // 52 is a DFS channel -> "52 · DFS"; 36 is not. + expect(dd.itemAsString!(52), '52 · DFS'); + expect(dd.itemAsString!(36), '36'); + }); + + testWidgets('#1025: DFS disabled hides 5GHz DFS channels from the dropdown', + (t) async { + await t.pumpWidget(host( + _radio( + band: '5GHz', + channel: 36, + autoChannelEnable: false, + isDfsEnabled: false, + possibleChannels: const [36, 40, 44, 48, 52, 100, 149], + ), + (_) {}, + )); + await openDialog(t); + + final dd = t.widget>(find.byType(AppDropdown)); + // -1 is the Auto sentinel; DFS channels 52 and 100 must be gone. + expect(dd.items, [-1, 36, 40, 44, 48, 149]); + }); + + testWidgets('#1025: DFS enabled keeps 5GHz DFS channels in the dropdown', + (t) async { + await t.pumpWidget(host( + _radio( + band: '5GHz', + channel: 36, + autoChannelEnable: false, + isDfsEnabled: true, + possibleChannels: const [36, 40, 44, 48, 52, 100, 149], + ), + (_) {}, + )); + await openDialog(t); + + final dd = t.widget>(find.byType(AppDropdown)); + expect(dd.items, [-1, 36, 40, 44, 48, 52, 100, 149]); + }); + + testWidgets( + '#1025: radio stuck on a DFS channel with DFS off — confirming without ' + 'moving does not rewrite to Auto', (t) async { + // SSH-confirmed firmware behaviour: disabling DFS leaves the radio on its + // manual DFS channel (e.g. 100). The dialog can only show Auto since 100 + // is filtered out, but merely opening and confirming must not mutate. + ({int channel, bool autoChannel})? captured; + var called = false; + await t.pumpWidget(host( + _radio( + band: '5GHz', + channel: 100, + autoChannelEnable: false, + isDfsEnabled: false, + possibleChannels: const [36, 40, 44, 48, 52, 100, 149], + ), + (r) { + captured = r; + called = true; + }, + )); + await openDialog(t); + + // 100 is filtered out, so the dialog opens on Auto. + final sw = t.widget(find.byType(AppSwitch)); + expect(sw.value, isTrue); + + await t.tap(find.text('Apply')); + await t.pumpAndSettle(); + + // No user interaction → no mutation. + expect(called, isTrue); + expect(captured, isNull); + }); + + testWidgets( + '#1025: after DFS-off fallback to Auto, actively picking a channel ' + 'still writes', (t) async { + // Guard against over-suppression: the no-op check must not block a real + // user selection made after the Auto fallback. + ({int channel, bool autoChannel})? captured; + await t.pumpWidget(host( + _radio( + band: '5GHz', + channel: 100, + autoChannelEnable: false, + isDfsEnabled: false, + possibleChannels: const [36, 40, 44, 48, 52, 100, 149], + ), + (r) => captured = r, + )); + await openDialog(t); + + // Turn Auto OFF, which selects the first manual channel (36). + await t.tap(find.byType(AppSwitch)); + await t.pumpAndSettle(); + + await t.tap(find.text('Apply')); + await t.pumpAndSettle(); + + expect(captured, isNotNull); + expect(captured!.autoChannel, isFalse); + expect(captured!.channel, 36); + }); + + // Fix (#1023): UI-kit v2.26.1 gates the AppDropdown tap gesture when + // onChanged is null (app_dropdown.dart:138,183), so passing a null + // onChanged genuinely blocks interaction — no consumer-side IgnorePointer + // is needed. These tests prove the disabled *behavior*, not the widget tree. + testWidgets( + 'Fix#1: Auto ON => dropdown disabled and its menu will not open', + (t) async { + await t.pumpWidget(host( + _radio(channel: 36, autoChannelEnable: true), + (_) {}, + )); + await openDialog(t); + + // Disabled: onChanged is null (2.26.1 gates the tap gesture on this). + final dd = t.widget>(find.byType(AppDropdown)); + expect(dd.onChanged, isNull); + + // The menu must not open when tapping the (disabled) dropdown. + await t.tap(find.byType(AppDropdown), warnIfMissed: false); + await t.pumpAndSettle(); + // No manual channel option (e.g. '40') becomes a tappable menu entry. + expect(find.text('40'), findsNothing); + }); + + testWidgets('Fix#1: Auto OFF => dropdown is enabled and interactive', + (t) async { + await t.pumpWidget(host( + _radio(channel: 36, autoChannelEnable: false), + (_) {}, + )); + await openDialog(t); + + final dd = t.widget>(find.byType(AppDropdown)); + expect(dd.onChanged, isNotNull); + }); + + testWidgets( + 'Fix#1: no manual channels => dropdown locked to Auto (disabled)', + (t) async { + await t.pumpWidget(host( + _radio( + channel: 36, autoChannelEnable: true, possibleChannels: const []), + (_) {}, + )); + await openDialog(t); + + final dd = t.widget>(find.byType(AppDropdown)); + expect(dd.onChanged, isNull); + }); + + testWidgets( + 'Fix#2: currently-using line is shown in Auto mode with the real channel', + (t) async { + await t.pumpWidget(host( + _radio(channel: 149, autoChannelEnable: true), + (_) {}, + )); + await openDialog(t); + + expect(find.text('Currently using: 149'), findsOneWidget); + }); + + testWidgets( + 'Fix#2: currently-using line is shown in manual mode with the channel', + (t) async { + await t.pumpWidget(host( + _radio(channel: 44, autoChannelEnable: false), + (_) {}, + )); + await openDialog(t); + + expect(find.text('Currently using: 44'), findsOneWidget); + }); + + testWidgets('AC2: dropdown items = Auto + possibleChannels', (t) async { + await t.pumpWidget(host( + _radio( + channel: 36, + autoChannelEnable: false, + possibleChannels: const [36, 40, 44]), + (_) {}, + )); + await openDialog(t); + + final dd = t.widget>(find.byType(AppDropdown)); + // -1 is the Auto sentinel. + expect(dd.items, [-1, 36, 40, 44]); + }); + }); +} diff --git a/test/page/devices/providers/device_detail_provider_test.dart b/test/page/devices/providers/device_detail_provider_test.dart index 7ff5e6d50..b4e78065b 100644 --- a/test/page/devices/providers/device_detail_provider_test.dart +++ b/test/page/devices/providers/device_detail_provider_test.dart @@ -1,7 +1,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; import 'package:privacy_gui/page/_shared/models/dhcp_reservation_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_network.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; +import 'package:privacy_gui/page/_shared/models/wifi_connection_info.dart'; import 'package:privacy_gui/page/devices/providers/device_detail_provider.dart'; import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; import 'package:privacy_gui/page/local_network/providers/dhcp_data_provider.dart'; @@ -11,36 +14,42 @@ void main() { // Shared test data // --------------------------------------------------------------------------- - const wifiDevice = DeviceUIModel( + final wifiDevice = ClientDevice( mac: 'AA:BB:CC:DD:EE:01', ip: '192.168.1.100', hostName: 'iPhone', isActive: true, - isWifi: true, - signalStrength: -55, + connectionType: ConnectionType.wifi, + wifi: WifiConnectionInfo(signalStrength: -55), ); - const ethernetDevice = DeviceUIModel( + final ethernetDevice = ClientDevice( mac: 'AA:BB:CC:DD:EE:02', ip: '192.168.1.101', hostName: 'Desktop', isActive: true, - isWifi: false, + connectionType: ConnectionType.wired, ); - const reservation = DhcpReservationUIModel( + final reservation = DhcpReservationUIModel( instancePath: 'Device.DHCPv4.Server.Pool.1.StaticAddress.1.', mac: 'AA:BB:CC:DD:EE:01', ip: '192.168.1.100', enable: true, ); - const devicesData = DevicesData( - deviceModels: [wifiDevice, ethernetDevice], + final devicesData = DevicesData( + meshNetwork: MeshNetwork( + master: MasterNode( + deviceId: 'GATEWAY', + model: 'TestRouter', + connectedClients: [wifiDevice, ethernetDevice], + ), + ), ); - const dhcpData = DhcpData( - clientModels: [], + final dhcpData = DhcpData( + clientModels: const [], reservationModels: [reservation], ); @@ -58,105 +67,107 @@ void main() { group('DeviceDetailProvider', () { // ----------------------------------------------------------------------- - // Basic lookup + // Success cases // ----------------------------------------------------------------------- - test('returns device and reservation by MAC', () async { + test('returns device with reservation', () async { final container = createContainer( devices: devicesData, dhcp: dhcpData, ); + addTearDown(container.dispose); + + // Wait for async providers to complete await container.read(devicesDataProvider.future); await container.read(dhcpDataProvider.future); - final detail = + final result = container.read(uspDeviceDetailProvider('AA:BB:CC:DD:EE:01')); - expect(detail.device, wifiDevice); - expect(detail.reservation, reservation); - expect(detail.hasReservation, isTrue); - container.dispose(); + expect(result.device, isNotNull); + expect(result.device!.mac, 'AA:BB:CC:DD:EE:01'); + expect(result.reservation, isNotNull); + expect(result.reservation!.mac, 'AA:BB:CC:DD:EE:01'); }); - test('returns device without reservation when no match', () async { + test('returns device without reservation', () async { final container = createContainer( devices: devicesData, dhcp: dhcpData, ); + addTearDown(container.dispose); + + // Wait for async providers to complete await container.read(devicesDataProvider.future); await container.read(dhcpDataProvider.future); - final detail = + final result = container.read(uspDeviceDetailProvider('AA:BB:CC:DD:EE:02')); - expect(detail.device, ethernetDevice); - expect(detail.reservation, isNull); - expect(detail.hasReservation, isFalse); - container.dispose(); + expect(result.device, isNotNull); + expect(result.device!.mac, 'AA:BB:CC:DD:EE:02'); + expect(result.reservation, isNull); }); // ----------------------------------------------------------------------- - // Case-insensitive matching + // Not found cases // ----------------------------------------------------------------------- - test('MAC lookup is case-insensitive', () async { + test('returns empty when device not found', () async { final container = createContainer( devices: devicesData, dhcp: dhcpData, ); + addTearDown(container.dispose); + + // Wait for async providers to complete await container.read(devicesDataProvider.future); await container.read(dhcpDataProvider.future); - final detail = - container.read(uspDeviceDetailProvider('aa:bb:cc:dd:ee:01')); + final result = + container.read(uspDeviceDetailProvider('NOT:FO:UN:DD:EV:IC')); - expect(detail.device, wifiDevice); - expect(detail.reservation, reservation); - container.dispose(); + expect(result, DeviceDetailState.empty()); }); // ----------------------------------------------------------------------- - // Edge cases + // Case-insensitive MAC lookup // ----------------------------------------------------------------------- - test('returns empty state when devices data is null', () { - final container = createContainer(devices: null, dhcp: null); - - final detail = - container.read(uspDeviceDetailProvider('AA:BB:CC:DD:EE:01')); - - expect(detail.device, isNull); - expect(detail.reservation, isNull); - container.dispose(); - }); - - test('returns null device for unknown MAC', () async { + test('finds device with lowercase MAC', () async { final container = createContainer( devices: devicesData, dhcp: dhcpData, ); + addTearDown(container.dispose); + + // Wait for async providers to complete await container.read(devicesDataProvider.future); + await container.read(dhcpDataProvider.future); - final detail = - container.read(uspDeviceDetailProvider('FF:FF:FF:FF:FF:FF')); + final result = + container.read(uspDeviceDetailProvider('aa:bb:cc:dd:ee:01')); - expect(detail.device, isNull); - container.dispose(); + expect(result.device, isNotNull); + expect(result.device!.mac.toUpperCase(), 'AA:BB:CC:DD:EE:01'); }); - test('returns device when DHCP data is unavailable', () async { + test('finds device with mixed-case MAC', () async { final container = createContainer( devices: devicesData, - dhcp: null, + dhcp: dhcpData, ); + addTearDown(container.dispose); + + // Wait for async providers to complete await container.read(devicesDataProvider.future); + await container.read(dhcpDataProvider.future); - final detail = - container.read(uspDeviceDetailProvider('AA:BB:CC:DD:EE:01')); + final result = + container.read(uspDeviceDetailProvider('Aa:Bb:Cc:Dd:Ee:01')); - expect(detail.device, wifiDevice); - expect(detail.reservation, isNull); - container.dispose(); + expect(result.device, isNotNull); + expect(result.device!.mac.toUpperCase(), 'AA:BB:CC:DD:EE:01'); }); }); } @@ -171,7 +182,13 @@ class _FakeDevicesNotifier extends AsyncNotifier _FakeDevicesNotifier(this._data); @override - Future build() async => _data ?? const DevicesData(); + Future build() async => + _data ?? + DevicesData( + meshNetwork: MeshNetwork( + master: MasterNode(deviceId: 'GATEWAY', model: 'TestRouter'), + ), + ); @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); diff --git a/test/page/devices/providers/device_filter_provider_test.dart b/test/page/devices/providers/device_filter_provider_test.dart index ca996bd5f..c079ded8f 100644 --- a/test/page/devices/providers/device_filter_provider_test.dart +++ b/test/page/devices/providers/device_filter_provider_test.dart @@ -1,93 +1,157 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/backhaul_info.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_network.dart'; import 'package:privacy_gui/page/_shared/models/mesh_topology_info.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; +import 'package:privacy_gui/page/_shared/models/wifi_connection_info.dart'; +import 'package:privacy_gui/page/_shared/utils/device_classifier.dart'; import 'package:privacy_gui/page/devices/providers/device_filter_provider.dart'; import 'package:privacy_gui/page/devices/providers/device_filter_state.dart'; import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; -import 'package:privacy_gui/page/topology/models/node_ui_model.dart'; void main() { // --------------------------------------------------------------------------- // Shared test data + // + // Migrated from the deleted DeviceUIModel/NodeUIModel to ClientDevice / + // MeshNetwork. isWifi/band/ssidName/signalStrength are exposed as getters + // via the WifiConnectionInfo attached to each WiFi device; wired devices + // carry no `wifi` (null). // --------------------------------------------------------------------------- - const wifiOnlineExcellent = DeviceUIModel( + final wifiOnlineExcellent = ClientDevice( mac: 'AA:AA:AA:AA:AA:01', ip: '192.168.1.101', hostName: 'iPhone', isActive: true, - isWifi: true, - band: '5GHz', - ssidName: 'Home', - signalStrength: -40, // excellent (>= -65) + connectionType: ConnectionType.wifi, + wifi: const WifiConnectionInfo( + band: '5GHz', + ssidName: 'Home', + signalStrength: -40, // excellent (>= -65) + ), parentNodeId: 'NODE-01', ); - const wifiOfflineHome = DeviceUIModel( + final wifiOfflineHome = ClientDevice( mac: 'AA:AA:AA:AA:AA:02', ip: '192.168.1.102', hostName: 'iPad', isActive: false, - isWifi: true, - // Offline devices in practice have null WiFi/node fields; keep a few set - // to prove filters pass them through even when leftover values linger. - band: '2.4GHz', - ssidName: 'Home', + connectionType: ConnectionType.wifi, + // No signalStrength — offline WiFi device with null RSSI. + wifi: const WifiConnectionInfo( + band: '2.4GHz', + ssidName: 'Home', + ), parentNodeId: 'NODE-01', ); - const ethernetOnline = DeviceUIModel( + final ethernetOnline = ClientDevice( mac: 'AA:AA:AA:AA:AA:03', ip: '192.168.1.103', hostName: 'Desktop', isActive: true, - isWifi: false, + connectionType: ConnectionType.wired, parentNodeId: 'NODE-01', ); - const wifiGuestGood = DeviceUIModel( + final wifiGuestGood = ClientDevice( mac: 'AA:AA:AA:AA:AA:04', ip: '192.168.1.104', hostName: 'GuestPhone', isActive: true, - isWifi: true, - band: '5GHz', - ssidName: 'Guest', - signalStrength: -68, // good (-65..-71) + connectionType: ConnectionType.wifi, + wifi: const WifiConnectionInfo( + band: '5GHz', + ssidName: 'Guest', + signalStrength: -68, // good (-65..-71) + ), parentNodeId: 'NODE-02', ); - const wifiOnlineNullRssi = DeviceUIModel( + final wifiOnlineNullRssi = ClientDevice( mac: 'AA:AA:AA:AA:AA:05', ip: '192.168.1.105', hostName: 'NewPhone', isActive: true, - isWifi: true, - band: '5GHz', - ssidName: 'Home', - // signalStrength intentionally null — firmware state where RSSI is absent. + connectionType: ConnectionType.wifi, + wifi: const WifiConnectionInfo( + band: '5GHz', + ssidName: 'Home', + // signalStrength intentionally null — firmware state where RSSI is absent. + ), + parentNodeId: 'NODE-01', + ); + + final wifiOnlineFair = ClientDevice( + mac: 'AA:AA:AA:AA:AA:06', + ip: '192.168.1.106', + hostName: 'Laptop', + isActive: true, + connectionType: ConnectionType.wifi, + wifi: const WifiConnectionInfo( + band: '2.4GHz', + ssidName: 'Home', + signalStrength: -75, // fair (-71..-78) + ), parentNodeId: 'NODE-01', ); - const allDevices = [ + final allDevices = [ wifiOnlineExcellent, wifiOfflineHome, ethernetOnline, wifiGuestGood, wifiOnlineNullRssi, + wifiOnlineFair, ]; - const devicesData = DevicesData( - deviceModels: allDevices, - meshTopology: MeshTopologyInfo( - nodes: [ - NodeUIModel(deviceId: 'NODE-01', model: 'MR7500'), - NodeUIModel(deviceId: 'NODE-02', model: 'MX5500'), - ], - clientToNodeMap: {}, - ), - ); + /// Builds a [DevicesData] whose clients live on the master node and whose + /// topology exposes both NODE-01 / NODE-02 by default. Node membership for + /// filtering is driven by each device's `parentNodeId`, so placing every + /// client on the master node is fine. + /// + /// [topologyNodes] overrides the topology node list (used by the node-orphan + /// reconciliation test that needs a node to disappear). + DevicesData createDevicesData( + List clients, { + List? topologyNodes, + }) { + return DevicesData( + meshNetwork: MeshNetwork( + master: MasterNode( + deviceId: 'NODE-01', + model: 'MR7500', + connectedClients: clients, + ), + slaves: [ + SlaveNode( + deviceId: 'NODE-02', + model: 'MX5500', + connectedClients: const [], + backhaul: const BackhaulInfo(mediaType: 'Wi-Fi'), + ), + ], + ), + meshTopology: MeshTopologyInfo( + nodes: topologyNodes ?? + [ + MasterNode(deviceId: 'NODE-01', model: 'MR7500'), + SlaveNode( + deviceId: 'NODE-02', + model: 'MX5500', + backhaul: const BackhaulInfo(mediaType: 'Wi-Fi'), + ), + ], + clientToNodeMap: const {}, + ), + ); + } + + final devicesData = createDevicesData(allDevices); ProviderContainer createContainer({DevicesData? data}) { return ProviderContainer( @@ -111,7 +175,7 @@ void main() { group('filteredDeviceListProvider', () { test('returns all devices with default filter', () async { final container = await createReadyContainer(); - expect(container.read(filteredDeviceListProvider), hasLength(5)); + expect(container.read(filteredDeviceListProvider), hasLength(6)); container.dispose(); }); @@ -124,7 +188,7 @@ void main() { final filtered = container.read(filteredDeviceListProvider); - expect(filtered, hasLength(4)); + expect(filtered, hasLength(5)); expect(filtered.every((d) => d.isActive), isTrue); container.dispose(); }); @@ -143,12 +207,12 @@ void main() { }); }); - group('Connection', () { + group('Connection (multi-select)', () { test('WiFi filter excludes ethernet devices', () async { final container = await createReadyContainer(); container .read(deviceFilterConfigProvider.notifier) - .setConnection(DeviceConnectionFilter.wifi); + .setConnections({ConnectionType.wifi}); final filtered = container.read(filteredDeviceListProvider); @@ -161,7 +225,7 @@ void main() { final container = await createReadyContainer(); container .read(deviceFilterConfigProvider.notifier) - .setConnection(DeviceConnectionFilter.ethernet); + .setConnections({ConnectionType.wired}); final filtered = container.read(filteredDeviceListProvider); @@ -169,90 +233,118 @@ void main() { expect(filtered.first.mac, ethernetOnline.mac); container.dispose(); }); + + test('selecting both WiFi and Ethernet is same as All', () async { + final container = await createReadyContainer(); + container + .read(deviceFilterConfigProvider.notifier) + .setConnections({ConnectionType.wifi, ConnectionType.wired}); + + final filtered = container.read(filteredDeviceListProvider); + + expect(filtered, hasLength(6)); + container.dispose(); + }); }); - group('Signal', () { + group('Signal (multi-select OR)', () { test('excellent bucket matches RSSI >= -65', () async { final container = await createReadyContainer(); container .read(deviceFilterConfigProvider.notifier) - .setSignal(DeviceSignalFilter.excellent); + .setSignals({DeviceSignalLevel.excellent}); final filtered = container.read(filteredDeviceListProvider); - // wifiOnlineExcellent (-40 excellent), ethernet passes through, - // wifiOnlineNullRssi passes through (null RSSI WiFi), - // wifiOfflineHome passes through (null RSSI WiFi). - // wifiGuestGood (-68 good) is excluded. expect(filtered.any((d) => d.mac == wifiOnlineExcellent.mac), isTrue); expect(filtered.any((d) => d.mac == wifiGuestGood.mac), isFalse); - expect(filtered.any((d) => d.mac == ethernetOnline.mac), isTrue); - expect(filtered.any((d) => d.mac == wifiOnlineNullRssi.mac), isTrue); + expect(filtered.any((d) => d.mac == wifiOnlineFair.mac), isFalse); container.dispose(); }); - test('unknown bucket matches only WiFi devices with null RSSI', () async { + test('selecting excellent + good matches both buckets (OR)', () async { final container = await createReadyContainer(); container .read(deviceFilterConfigProvider.notifier) - .setSignal(DeviceSignalFilter.unknown); + .setSignals({DeviceSignalLevel.excellent, DeviceSignalLevel.good}); final filtered = container.read(filteredDeviceListProvider); - // wifiOnlineNullRssi and wifiOfflineHome are WiFi with null RSSI. - expect(filtered, hasLength(2)); - expect(filtered.map((d) => d.mac), - containsAll([wifiOnlineNullRssi.mac, wifiOfflineHome.mac])); + expect(filtered.any((d) => d.mac == wifiOnlineExcellent.mac), isTrue); + expect(filtered.any((d) => d.mac == wifiGuestGood.mac), isTrue); + expect(filtered.any((d) => d.mac == wifiOnlineFair.mac), isFalse); + container.dispose(); + }); + + test('includeUnknownSignal matches only WiFi devices with null RSSI', + () async { + final container = await createReadyContainer(); + container + .read(deviceFilterConfigProvider.notifier) + .setIncludeUnknownSignal(true); + + final filtered = container.read(filteredDeviceListProvider); + + // Should include null-RSSI WiFi devices + expect(filtered.map((d) => d.mac), contains(wifiOnlineNullRssi.mac)); + // Should exclude WiFi devices with known RSSI (BUG FIX verification) + expect(filtered.any((d) => d.mac == wifiOnlineExcellent.mac), isFalse); + expect(filtered.any((d) => d.mac == wifiGuestGood.mac), isFalse); + expect(filtered.any((d) => d.mac == wifiOnlineFair.mac), isFalse); + // Ethernet devices should also be excluded (WiFi-only filter) + expect(filtered.any((d) => d.mac == ethernetOnline.mac), isFalse); container.dispose(); }); }); - group('Node', () { - test('node filter excludes devices on other nodes', () async { + group('Node (multi-select OR)', () { + test('single node filter excludes devices on other nodes', () async { final container = await createReadyContainer(); container .read(deviceFilterConfigProvider.notifier) - .setNodeId('NODE-02'); + .setNodeIds({'NODE-02'}); final filtered = container.read(filteredDeviceListProvider); - // wifiGuestGood is on NODE-02. + expect(filtered.map((d) => d.mac), contains(wifiGuestGood.mac)); // wifiOfflineHome is offline → passes through despite being on NODE-01. - expect(filtered.map((d) => d.mac), - containsAll([wifiGuestGood.mac, wifiOfflineHome.mac])); + expect(filtered.map((d) => d.mac), contains(wifiOfflineHome.mac)); expect(filtered.any((d) => d.mac == wifiOnlineExcellent.mac), isFalse); container.dispose(); }); + test('multi-node filter shows devices on either node (OR)', () async { + final container = await createReadyContainer(); + container + .read(deviceFilterConfigProvider.notifier) + .setNodeIds({'NODE-01', 'NODE-02'}); + + final filtered = container.read(filteredDeviceListProvider); + + expect(filtered, hasLength(6)); + container.dispose(); + }); + test( 'regression: Status=All + Node=X must not drop offline devices ' '(they have null parentNodeId in reality)', () async { // Replace wifiOfflineHome with one that has a null parentNodeId — the // realistic offline state. Filter by NODE-01 must still include it. - const offlineWithNullNode = DeviceUIModel( + final offlineWithNullNode = ClientDevice( mac: 'AA:AA:AA:AA:AA:02', ip: '192.168.1.102', hostName: 'iPad', isActive: false, - isWifi: true, + connectionType: ConnectionType.wifi, // parentNodeId: null — realistic offline state. ); final container = await createReadyContainer( - data: const DevicesData( - deviceModels: [wifiOnlineExcellent, offlineWithNullNode], - meshTopology: MeshTopologyInfo( - nodes: [ - NodeUIModel(deviceId: 'NODE-01', model: 'MR7500'), - NodeUIModel(deviceId: 'NODE-02', model: 'MX5500'), - ], - clientToNodeMap: {}, - ), - ), + data: createDevicesData([wifiOnlineExcellent, offlineWithNullNode]), ); container .read(deviceFilterConfigProvider.notifier) - .setNodeId('NODE-01'); + .setNodeIds({'NODE-01'}); final filtered = container.read(filteredDeviceListProvider); @@ -262,33 +354,129 @@ void main() { }); }); - group('SSID / Band', () { - test('SSID filter shows matching WiFi devices and all ethernet devices', - () async { + group('SSID / Band (multi-select OR)', () { + test('single SSID filter shows matching WiFi devices', () async { final container = await createReadyContainer(); container .read(deviceFilterConfigProvider.notifier) - .setSsidName('Guest'); + .setSsidNames({'Guest'}); final filtered = container.read(filteredDeviceListProvider); expect(filtered.any((d) => d.mac == wifiGuestGood.mac), isTrue); - expect(filtered.any((d) => d.mac == ethernetOnline.mac), isTrue); expect(filtered.any((d) => d.mac == wifiOnlineExcellent.mac), isFalse); container.dispose(); }); - test('band filter shows matching WiFi devices and all ethernet devices', - () async { + test('multi-SSID filter shows devices on either SSID (OR)', () async { final container = await createReadyContainer(); - container.read(deviceFilterConfigProvider.notifier).setBand('2.4GHz'); + container + .read(deviceFilterConfigProvider.notifier) + .setSsidNames({'Home', 'Guest'}); + + final filtered = container.read(filteredDeviceListProvider); + + expect(filtered.any((d) => d.mac == wifiOnlineExcellent.mac), isTrue); + expect(filtered.any((d) => d.mac == wifiGuestGood.mac), isTrue); + container.dispose(); + }); + + test('band filter shows matching WiFi devices', () async { + final container = await createReadyContainer(); + container + .read(deviceFilterConfigProvider.notifier) + .setBands({'2.4GHz'}); final filtered = container.read(filteredDeviceListProvider); expect(filtered.any((d) => d.mac == wifiOfflineHome.mac), isTrue); + expect(filtered.any((d) => d.mac == wifiOnlineFair.mac), isTrue); + expect(filtered.any((d) => d.mac == wifiOnlineExcellent.mac), isFalse); + container.dispose(); + }); + }); + + group('Cross-dimension AND logic', () { + test('Signal + Band combines with AND', () async { + final container = await createReadyContainer(); + final notifier = container.read(deviceFilterConfigProvider.notifier); + notifier.setSignals({DeviceSignalLevel.excellent}); + notifier.setBands({'5GHz'}); + + final filtered = container.read(filteredDeviceListProvider); + + expect(filtered.any((d) => d.mac == wifiOnlineExcellent.mac), isTrue); + expect(filtered.any((d) => d.mac == wifiGuestGood.mac), isFalse); + container.dispose(); + }); + + test('SSID + Signal combines with AND', () async { + final container = await createReadyContainer(); + final notifier = container.read(deviceFilterConfigProvider.notifier); + notifier.setSsidNames({'Home'}); + notifier.setSignals({DeviceSignalLevel.excellent}); + + final filtered = container.read(filteredDeviceListProvider); + + expect(filtered.any((d) => d.mac == wifiOnlineExcellent.mac), isTrue); + expect(filtered.any((d) => d.mac == wifiOnlineFair.mac), isFalse); + container.dispose(); + }); + }); + + group('Bug fix: WiFi-only filters exclude Ethernet', () { + test('Signal filter excludes Ethernet when Connection is empty', + () async { + final container = await createReadyContainer(); + container + .read(deviceFilterConfigProvider.notifier) + .setSignals({DeviceSignalLevel.excellent}); + + final filtered = container.read(filteredDeviceListProvider); + + expect(filtered.any((d) => d.mac == wifiOnlineExcellent.mac), isTrue); + expect(filtered.any((d) => d.mac == ethernetOnline.mac), isFalse); + container.dispose(); + }); + + test( + 'Signal filter includes Ethernet when Connection explicitly includes wired', + () async { + final container = await createReadyContainer(); + final notifier = container.read(deviceFilterConfigProvider.notifier); + notifier.setSignals({DeviceSignalLevel.excellent}); + notifier.setConnections({ConnectionType.wifi, ConnectionType.wired}); + + final filtered = container.read(filteredDeviceListProvider); + + expect(filtered.any((d) => d.mac == wifiOnlineExcellent.mac), isTrue); expect(filtered.any((d) => d.mac == ethernetOnline.mac), isTrue); container.dispose(); }); + + test('SSID filter excludes Ethernet when Connection is empty', () async { + final container = await createReadyContainer(); + container + .read(deviceFilterConfigProvider.notifier) + .setSsidNames({'Home'}); + + final filtered = container.read(filteredDeviceListProvider); + + expect(filtered.any((d) => d.mac == wifiOnlineExcellent.mac), isTrue); + expect(filtered.any((d) => d.mac == ethernetOnline.mac), isFalse); + container.dispose(); + }); + + test('Band filter excludes Ethernet when Connection is empty', () async { + final container = await createReadyContainer(); + container.read(deviceFilterConfigProvider.notifier).setBands({'5GHz'}); + + final filtered = container.read(filteredDeviceListProvider); + + expect(filtered.any((d) => d.mac == wifiOnlineExcellent.mac), isTrue); + expect(filtered.any((d) => d.mac == ethernetOnline.mac), isFalse); + container.dispose(); + }); }); group('Search', () { @@ -339,53 +527,66 @@ void main() { final container = await createReadyContainer(); final notifier = container.read(deviceFilterConfigProvider.notifier); - notifier.setConnection(DeviceConnectionFilter.wifi); - notifier.setSignal(DeviceSignalFilter.good); - notifier.setSsidName('Home'); - notifier.setBand('5GHz'); - notifier.setNodeId('NODE-01'); + notifier.setConnections({ConnectionType.wifi}); + notifier.setSignals({DeviceSignalLevel.good}); + notifier.setSsidNames({'Home'}); + notifier.setBands({'5GHz'}); + notifier.setNodeIds({'NODE-01'}); notifier.setStatus(DeviceStatusFilter.offline); final state = container.read(deviceFilterConfigProvider); expect(state.status, DeviceStatusFilter.offline); - expect(state.connection, DeviceConnectionFilter.all); - expect(state.signal, DeviceSignalFilter.all); - expect(state.ssidName, isNull); - expect(state.band, isNull); - expect(state.nodeId, isNull); + expect(state.connections, isEmpty); + expect(state.signals, isEmpty); + expect(state.includeUnknownSignal, isFalse); + expect(state.ssidNames, isEmpty); + expect(state.bands, isEmpty); + expect(state.nodeIds, isEmpty); container.dispose(); }); - test('selecting Ethernet clears WiFi-only dimensions but keeps Node', + test('selecting Ethernet-only clears WiFi-only dimensions but keeps Node', () async { final container = await createReadyContainer(); final notifier = container.read(deviceFilterConfigProvider.notifier); - notifier.setSignal(DeviceSignalFilter.good); - notifier.setSsidName('Home'); - notifier.setBand('5GHz'); - notifier.setNodeId('NODE-01'); - notifier.setConnection(DeviceConnectionFilter.ethernet); + notifier.setSignals({DeviceSignalLevel.good}); + notifier.setSsidNames({'Home'}); + notifier.setBands({'5GHz'}); + notifier.setNodeIds({'NODE-01'}); + notifier.setConnections({ConnectionType.wired}); final state = container.read(deviceFilterConfigProvider); - expect(state.connection, DeviceConnectionFilter.ethernet); - expect(state.signal, DeviceSignalFilter.all); - expect(state.ssidName, isNull); - expect(state.band, isNull); - expect(state.nodeId, 'NODE-01'); + expect(state.connections, {ConnectionType.wired}); + expect(state.signals, isEmpty); + expect(state.ssidNames, isEmpty); + expect(state.bands, isEmpty); + expect(state.nodeIds, {'NODE-01'}); container.dispose(); }); - test('switching back from Ethernet to All does not restore WiFi filters', + test('selecting both WiFi and Ethernet does not clear WiFi filters', () async { final container = await createReadyContainer(); final notifier = container.read(deviceFilterConfigProvider.notifier); - notifier.setSsidName('Home'); - notifier.setConnection(DeviceConnectionFilter.ethernet); - notifier.setConnection(DeviceConnectionFilter.all); + notifier.setSsidNames({'Home'}); + notifier.setConnections({ConnectionType.wifi, ConnectionType.wired}); - expect(container.read(deviceFilterConfigProvider).ssidName, isNull); + expect(container.read(deviceFilterConfigProvider).ssidNames, {'Home'}); + container.dispose(); + }); + + test('switching from Ethernet-only to All does not restore WiFi filters', + () async { + final container = await createReadyContainer(); + final notifier = container.read(deviceFilterConfigProvider.notifier); + + notifier.setSsidNames({'Home'}); + notifier.setConnections({ConnectionType.wired}); + notifier.setConnections({}); + + expect(container.read(deviceFilterConfigProvider).ssidNames, isEmpty); container.dispose(); }); @@ -395,7 +596,7 @@ void main() { notifier.setStatus(DeviceStatusFilter.online); notifier.setSearchQuery('iphone'); - notifier.setSsidName('Home'); + notifier.setSsidNames({'Home'}); notifier.clearAll(); final state = container.read(deviceFilterConfigProvider); @@ -410,65 +611,54 @@ void main() { // --------------------------------------------------------------------------- group('DeviceFilterNotifier orphan reconciliation', () { - test('nulls SSID when it disappears from options after data refresh', - () async { + test('removes orphan SSIDs when they disappear from options', () async { final container = await createReadyContainer(); - container.read(deviceFilterConfigProvider.notifier).setSsidName('Guest'); + container + .read(deviceFilterConfigProvider.notifier) + .setSsidNames({'Guest', 'Home'}); // Push a new dataset without any Guest device. final notifier = container.read(devicesDataProvider.notifier) as _FakeDevicesNotifier; - notifier.emit(const DevicesData( - deviceModels: [wifiOnlineExcellent], - meshTopology: MeshTopologyInfo( - nodes: [NodeUIModel(deviceId: 'NODE-01', model: 'MR7500')], - clientToNodeMap: {}, - ), - )); + notifier.emit(createDevicesData([wifiOnlineExcellent])); await Future.value(); - expect(container.read(deviceFilterConfigProvider).ssidName, isNull); + expect(container.read(deviceFilterConfigProvider).ssidNames, {'Home'}); container.dispose(); }); - test('nulls nodeId when selected node disappears', () async { + test('removes orphan nodeIds when selected node disappears', () async { final container = await createReadyContainer(); - container.read(deviceFilterConfigProvider.notifier).setNodeId('NODE-02'); + container + .read(deviceFilterConfigProvider.notifier) + .setNodeIds({'NODE-01', 'NODE-02'}); final notifier = container.read(devicesDataProvider.notifier) as _FakeDevicesNotifier; - notifier.emit(const DevicesData( - deviceModels: [wifiOnlineExcellent], - meshTopology: MeshTopologyInfo( - nodes: [NodeUIModel(deviceId: 'NODE-01', model: 'MR7500')], - clientToNodeMap: {}, - ), + notifier.emit(createDevicesData( + [wifiOnlineExcellent], + topologyNodes: [MasterNode(deviceId: 'NODE-01', model: 'MR7500')], )); await Future.value(); - expect(container.read(deviceFilterConfigProvider).nodeId, isNull); + expect(container.read(deviceFilterConfigProvider).nodeIds, {'NODE-01'}); container.dispose(); }); - test('resets Signal=unknown when no null-RSSI devices remain', () async { + test('resets includeUnknownSignal when no null-RSSI devices remain', + () async { final container = await createReadyContainer(); container .read(deviceFilterConfigProvider.notifier) - .setSignal(DeviceSignalFilter.unknown); + .setIncludeUnknownSignal(true); final notifier = container.read(devicesDataProvider.notifier) as _FakeDevicesNotifier; - notifier.emit(const DevicesData( - deviceModels: [wifiOnlineExcellent, ethernetOnline], - meshTopology: MeshTopologyInfo( - nodes: [NodeUIModel(deviceId: 'NODE-01', model: 'MR7500')], - clientToNodeMap: {}, - ), - )); + notifier.emit(createDevicesData([wifiOnlineExcellent, ethernetOnline])); await Future.value(); - expect(container.read(deviceFilterConfigProvider).signal, - DeviceSignalFilter.all); + expect(container.read(deviceFilterConfigProvider).includeUnknownSignal, + isFalse); container.dispose(); }); }); @@ -494,10 +684,8 @@ void main() { test('hasUnknownSignalDevices false when every WiFi device has RSSI', () async { final container = await createReadyContainer( - data: const DevicesData( - deviceModels: [wifiOnlineExcellent, wifiGuestGood, ethernetOnline], - meshTopology: MeshTopologyInfo(nodes: [], clientToNodeMap: {}), - ), + data: createDevicesData( + [wifiOnlineExcellent, wifiGuestGood, ethernetOnline]), ); expect( @@ -525,19 +713,239 @@ void main() { }); // --------------------------------------------------------------------------- - // signalBucketOf + // signalLevelOf // --------------------------------------------------------------------------- - group('signalBucketOf', () { + group('signalLevelOf', () { test('matches project-wide RSSI thresholds (-65 / -71 / -78)', () { - expect(signalBucketOf(null), DeviceSignalFilter.unknown); - expect(signalBucketOf(-30), DeviceSignalFilter.excellent); - expect(signalBucketOf(-65), DeviceSignalFilter.excellent); - expect(signalBucketOf(-66), DeviceSignalFilter.good); - expect(signalBucketOf(-71), DeviceSignalFilter.good); - expect(signalBucketOf(-72), DeviceSignalFilter.fair); - expect(signalBucketOf(-78), DeviceSignalFilter.fair); - expect(signalBucketOf(-79), DeviceSignalFilter.poor); + expect(signalLevelOf(-30), DeviceSignalLevel.excellent); + expect(signalLevelOf(-65), DeviceSignalLevel.excellent); + expect(signalLevelOf(-66), DeviceSignalLevel.good); + expect(signalLevelOf(-71), DeviceSignalLevel.good); + expect(signalLevelOf(-72), DeviceSignalLevel.fair); + expect(signalLevelOf(-78), DeviceSignalLevel.fair); + expect(signalLevelOf(-79), DeviceSignalLevel.poor); + }); + }); + + // --------------------------------------------------------------------------- + // DeviceFilterConfig state helpers + // --------------------------------------------------------------------------- + + group('DeviceFilterConfig helpers', () { + test('hasWifiOnlyFilter returns true when signal is set', () { + final config = + DeviceFilterConfig(signals: const {DeviceSignalLevel.excellent}); + expect(config.hasWifiOnlyFilter, isTrue); + }); + + test('hasWifiOnlyFilter returns true when includeUnknownSignal is true', + () { + const config = DeviceFilterConfig(includeUnknownSignal: true); + expect(config.hasWifiOnlyFilter, isTrue); + }); + + test('hasWifiOnlyFilter returns true when ssidNames is set', () { + final config = DeviceFilterConfig(ssidNames: const {'Home'}); + expect(config.hasWifiOnlyFilter, isTrue); + }); + + test('hasWifiOnlyFilter returns true when bands is set', () { + final config = DeviceFilterConfig(bands: const {'5GHz'}); + expect(config.hasWifiOnlyFilter, isTrue); + }); + + test('hasWifiOnlyFilter returns false when none are set', () { + const config = DeviceFilterConfig(); + expect(config.hasWifiOnlyFilter, isFalse); + }); + + test('isEthernetOnly returns true only for single wired selection', () { + expect( + DeviceFilterConfig(connections: const {ConnectionType.wired}) + .isEthernetOnly, + isTrue, + ); + expect( + DeviceFilterConfig(connections: const { + ConnectionType.wifi, + ConnectionType.wired, + }).isEthernetOnly, + isFalse, + ); + expect( + const DeviceFilterConfig().isEthernetOnly, + isFalse, + ); + }); + + test('activeCount counts non-empty dimensions', () { + expect(const DeviceFilterConfig().activeCount, 0); + expect( + DeviceFilterConfig(connections: const {ConnectionType.wifi}) + .activeCount, + 1, + ); + expect( + DeviceFilterConfig( + connections: const {ConnectionType.wifi}, + signals: const {DeviceSignalLevel.excellent}, + ).activeCount, + 2, + ); + }); + + test('activeCount includes deviceCategories and privateMac', () { + expect( + DeviceFilterConfig(deviceCategories: const {DeviceCategory.phone}) + .activeCount, + 1, + ); + expect( + const DeviceFilterConfig(privateMac: PrivateMacFilter.privateOnly) + .activeCount, + 1, + ); + expect( + DeviceFilterConfig( + deviceCategories: const {DeviceCategory.phone}, + privateMac: PrivateMacFilter.privateOnly, + ).activeCount, + 2, + ); + }); + }); + + // --------------------------------------------------------------------------- + // Device Category filter + // + // Classification is driven by DeviceClassifier.classify(hostname, mac). + // To keep these tests independent of OUI-database state and MAC-fallback + // heuristics, every fixture below pins its category via an explicit HOSTNAME + // pattern and uses an OUI-registered (non-randomized) MAC so the outcome is + // decided solely by the hostname rule under test: + // iPhone → phone (hostname pattern `iphone`) + // Galaxy S23 → phone (hostname pattern `galaxy s`) + // iPad → tablet (hostname pattern `ipad`) + // Desktop-PC → computer (hostname pattern `desktop`) + // --------------------------------------------------------------------------- + + group('filteredDeviceListProvider Device Category filter', () { + // OUI-registered first byte (0x00, bit 1 = 0) → never a randomized MAC, so + // classification cannot fall back to the private-MAC heuristic. + ClientDevice categoryDevice(String mac, String hostName) => ClientDevice( + mac: mac, + ip: '192.168.5.${mac.hashCode.abs() % 250 + 1}', + hostName: hostName, + isActive: true, + connectionType: ConnectionType.wifi, + wifi: const WifiConnectionInfo(band: '5GHz', ssidName: 'Home'), + ); + + final phoneA = categoryDevice('00:11:22:00:00:01', 'iPhone'); + final phoneB = categoryDevice('00:11:22:00:00:02', 'Galaxy S23'); + final tablet = categoryDevice('00:11:22:00:00:03', 'iPad'); + final computer = categoryDevice('00:11:22:00:00:04', 'Desktop-PC'); + final categoryData = createDevicesData([phoneA, phoneB, tablet, computer]); + + test('filters by a single device category (hostname-pinned)', () async { + final container = await createReadyContainer(data: categoryData); + container + .read(deviceFilterConfigProvider.notifier) + .setDeviceCategories({DeviceCategory.phone}); + + final filtered = container.read(filteredDeviceListProvider); + final macs = filtered.map((d) => d.mac).toSet(); + + // Both phones match; tablet and computer are excluded. + expect(macs, {phoneA.mac, phoneB.mac}); + container.dispose(); + }); + + test('multi-select device categories uses OR logic', () async { + final container = await createReadyContainer(data: categoryData); + container.read(deviceFilterConfigProvider.notifier).setDeviceCategories({ + DeviceCategory.phone, + DeviceCategory.tablet, + }); + + final filtered = container.read(filteredDeviceListProvider); + final macs = filtered.map((d) => d.mac).toSet(); + + // phones (OR) tablet match; computer excluded. + expect(macs, {phoneA.mac, phoneB.mac, tablet.mac}); + expect(macs, isNot(contains(computer.mac))); + container.dispose(); + }); + }); + + // --------------------------------------------------------------------------- + // Private MAC filter + // --------------------------------------------------------------------------- + + group('filteredDeviceListProvider Private MAC filter', () { + // Private MAC: bit 1 of first byte = 1 (locally administered) + // 0x02 = 00000010, bit 1 = 1 -> private + final privateMacDevice = ClientDevice( + mac: '02:00:00:AA:AA:01', // Locally administered (private) + ip: '192.168.1.200', + hostName: 'PrivatePhone', + isActive: true, + connectionType: ConnectionType.wifi, + wifi: const WifiConnectionInfo(band: '5GHz', ssidName: 'Home'), + ); + + // Public MAC: bit 1 of first byte = 0 (OUI registered) + // 0x00 = 00000000, bit 1 = 0 -> public + final publicMacDevice = ClientDevice( + mac: '00:11:22:33:44:55', // OUI registered (public) + ip: '192.168.1.201', + hostName: 'PublicPhone', + isActive: true, + connectionType: ConnectionType.wifi, + wifi: const WifiConnectionInfo(band: '5GHz', ssidName: 'Home'), + ); + + test('privateOnly shows only private MAC devices', () async { + final container = await createReadyContainer( + data: createDevicesData([privateMacDevice, publicMacDevice]), + ); + container + .read(deviceFilterConfigProvider.notifier) + .setPrivateMac(PrivateMacFilter.privateOnly); + + final filtered = container.read(filteredDeviceListProvider); + + expect(filtered, hasLength(1)); + expect(filtered.first.hostName, 'PrivatePhone'); + container.dispose(); + }); + + test('publicOnly shows only public MAC devices', () async { + final container = await createReadyContainer( + data: createDevicesData([privateMacDevice, publicMacDevice]), + ); + container + .read(deviceFilterConfigProvider.notifier) + .setPrivateMac(PrivateMacFilter.publicOnly); + + final filtered = container.read(filteredDeviceListProvider); + + expect(filtered, hasLength(1)); + expect(filtered.first.hostName, 'PublicPhone'); + container.dispose(); + }); + + test('all shows both private and public MAC devices', () async { + final container = await createReadyContainer( + data: createDevicesData([privateMacDevice, publicMacDevice]), + ); + // Default is PrivateMacFilter.all, no need to set + + final filtered = container.read(filteredDeviceListProvider); + + expect(filtered, hasLength(2)); + container.dispose(); }); }); } @@ -553,7 +961,16 @@ class _FakeDevicesNotifier extends AsyncNotifier DevicesData? _data; @override - Future build() async => _data ?? const DevicesData(); + Future build() async { + if (_data == null) { + return DevicesData( + meshNetwork: MeshNetwork( + master: MasterNode(deviceId: 'GATEWAY', model: 'Unknown'), + ), + ); + } + return _data!; + } /// Push a new dataset so that `ref.listen(deviceFilterOptionsProvider)` /// in the notifier fires reconciliation. diff --git a/test/page/devices/providers/device_filter_state_test.dart b/test/page/devices/providers/device_filter_state_test.dart index 5114a27f0..afa490b51 100644 --- a/test/page/devices/providers/device_filter_state_test.dart +++ b/test/page/devices/providers/device_filter_state_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; import 'package:privacy_gui/page/devices/providers/device_filter_state.dart'; void main() { @@ -9,9 +10,12 @@ void main() { expect(config.isActive, isFalse); expect(config.searchQuery, isEmpty); expect(config.status, DeviceStatusFilter.all); - expect(config.nodeId, isNull); - expect(config.ssidName, isNull); - expect(config.band, isNull); + expect(config.connections, isEmpty); + expect(config.signals, isEmpty); + expect(config.includeUnknownSignal, isFalse); + expect(config.nodeIds, isEmpty); + expect(config.ssidNames, isEmpty); + expect(config.bands, isEmpty); }); test('isActive true when status is not all', () { @@ -19,18 +23,33 @@ void main() { expect(config.isActive, isTrue); }); - test('isActive true when nodeId is set', () { - const config = DeviceFilterConfig(nodeId: 'node1'); + test('isActive true when connections is set', () { + const config = DeviceFilterConfig(connections: {ConnectionType.wifi}); expect(config.isActive, isTrue); }); - test('isActive true when ssidName is set', () { - const config = DeviceFilterConfig(ssidName: 'Home'); + test('isActive true when signals is set', () { + const config = DeviceFilterConfig(signals: {DeviceSignalLevel.excellent}); expect(config.isActive, isTrue); }); - test('isActive true when band is set', () { - const config = DeviceFilterConfig(band: '5GHz'); + test('isActive true when includeUnknownSignal is true', () { + const config = DeviceFilterConfig(includeUnknownSignal: true); + expect(config.isActive, isTrue); + }); + + test('isActive true when nodeIds is set', () { + const config = DeviceFilterConfig(nodeIds: {'node1'}); + expect(config.isActive, isTrue); + }); + + test('isActive true when ssidNames is set', () { + const config = DeviceFilterConfig(ssidNames: {'Home'}); + expect(config.isActive, isTrue); + }); + + test('isActive true when bands is set', () { + const config = DeviceFilterConfig(bands: {'5GHz'}); expect(config.isActive, isTrue); }); @@ -44,57 +63,153 @@ void main() { final updated = config.copyWith( searchQuery: 'test', status: DeviceStatusFilter.offline, - nodeId: () => 'node1', - ssidName: () => 'Guest', - band: () => '2.4GHz', + connections: const {ConnectionType.wifi}, + signals: const {DeviceSignalLevel.good}, + includeUnknownSignal: true, + nodeIds: () => const {'node1'}, + ssidNames: () => const {'Guest'}, + bands: () => const {'2.4GHz'}, ); expect(updated.searchQuery, 'test'); expect(updated.status, DeviceStatusFilter.offline); - expect(updated.nodeId, 'node1'); - expect(updated.ssidName, 'Guest'); - expect(updated.band, '2.4GHz'); + expect(updated.connections, const {ConnectionType.wifi}); + expect(updated.signals, const {DeviceSignalLevel.good}); + expect(updated.includeUnknownSignal, isTrue); + expect(updated.nodeIds, const {'node1'}); + expect(updated.ssidNames, const {'Guest'}); + expect(updated.bands, const {'2.4GHz'}); }); - test('copyWith nullable fields can be cleared with null-returning closure', + test('copyWith Set fields can be cleared with empty-set-returning closure', () { const config = DeviceFilterConfig( - nodeId: 'node1', - ssidName: 'Home', - band: '5GHz', + nodeIds: {'node1'}, + ssidNames: {'Home'}, + bands: {'5GHz'}, ); final cleared = config.copyWith( - nodeId: () => null, - ssidName: () => null, - band: () => null, + nodeIds: () => const {}, + ssidNames: () => const {}, + bands: () => const {}, ); - expect(cleared.nodeId, isNull); - expect(cleared.ssidName, isNull); - expect(cleared.band, isNull); + expect(cleared.nodeIds, isEmpty); + expect(cleared.ssidNames, isEmpty); + expect(cleared.bands, isEmpty); }); test('copyWith preserves unchanged fields', () { const config = DeviceFilterConfig( searchQuery: 'phone', status: DeviceStatusFilter.online, - nodeId: 'node1', + nodeIds: {'node1'}, ); final updated = config.copyWith(searchQuery: 'laptop'); expect(updated.searchQuery, 'laptop'); expect(updated.status, DeviceStatusFilter.online); - expect(updated.nodeId, 'node1'); + expect(updated.nodeIds, const {'node1'}); }); test('equatable compares all fields', () { - const a = DeviceFilterConfig(searchQuery: 'test', nodeId: 'n1'); - const b = DeviceFilterConfig(searchQuery: 'test', nodeId: 'n1'); - const c = DeviceFilterConfig(searchQuery: 'test', nodeId: 'n2'); + const a = DeviceFilterConfig(searchQuery: 'test', nodeIds: {'n1'}); + const b = DeviceFilterConfig(searchQuery: 'test', nodeIds: {'n1'}); + const c = DeviceFilterConfig(searchQuery: 'test', nodeIds: {'n2'}); expect(a, equals(b)); expect(a, isNot(equals(c))); }); + + test('hasWifiOnlyFilter returns true when any WiFi-specific filter is set', + () { + expect( + const DeviceFilterConfig(signals: {DeviceSignalLevel.excellent}) + .hasWifiOnlyFilter, + isTrue, + ); + expect( + const DeviceFilterConfig(includeUnknownSignal: true).hasWifiOnlyFilter, + isTrue, + ); + expect( + const DeviceFilterConfig(ssidNames: {'Home'}).hasWifiOnlyFilter, + isTrue, + ); + expect( + const DeviceFilterConfig(bands: {'5GHz'}).hasWifiOnlyFilter, + isTrue, + ); + expect( + const DeviceFilterConfig().hasWifiOnlyFilter, + isFalse, + ); + }); + + test('isEthernetOnly returns true only for single wired selection', () { + expect( + const DeviceFilterConfig(connections: {ConnectionType.wired}) + .isEthernetOnly, + isTrue, + ); + expect( + const DeviceFilterConfig( + connections: {ConnectionType.wifi, ConnectionType.wired}) + .isEthernetOnly, + isFalse, + ); + expect( + const DeviceFilterConfig(connections: {ConnectionType.wifi}) + .isEthernetOnly, + isFalse, + ); + expect( + const DeviceFilterConfig().isEthernetOnly, + isFalse, + ); + }); + + test('activeCount counts non-empty dimensions correctly', () { + expect(const DeviceFilterConfig().activeCount, 0); + expect( + const DeviceFilterConfig(connections: {ConnectionType.wifi}) + .activeCount, + 1, + ); + expect( + const DeviceFilterConfig( + connections: {ConnectionType.wifi}, + signals: {DeviceSignalLevel.excellent}, + ).activeCount, + 2, + ); + expect( + const DeviceFilterConfig( + status: DeviceStatusFilter.online, + connections: {ConnectionType.wifi}, + signals: {DeviceSignalLevel.excellent}, + nodeIds: {'node1'}, + ssidNames: {'Home'}, + bands: {'5GHz'}, + ).activeCount, + 6, + ); + }); + + test('activeCountExcludingStatus excludes status from count', () { + expect( + const DeviceFilterConfig(status: DeviceStatusFilter.online) + .activeCountExcludingStatus, + 0, + ); + expect( + const DeviceFilterConfig( + status: DeviceStatusFilter.online, + connections: {ConnectionType.wifi}, + ).activeCountExcludingStatus, + 1, + ); + }); }); group('DeviceFilterOptions', () { @@ -104,6 +219,7 @@ void main() { expect(options.nodes, isEmpty); expect(options.ssids, isEmpty); expect(options.bands, isEmpty); + expect(options.hasUnknownSignalDevices, isFalse); }); }); } diff --git a/test/page/devices/providers/devices_data_provider_test.dart b/test/page/devices/providers/devices_data_provider_test.dart index ca0f4a279..37701d9a5 100644 --- a/test/page/devices/providers/devices_data_provider_test.dart +++ b/test/page/devices/providers/devices_data_provider_test.dart @@ -8,7 +8,9 @@ import 'package:mocktail/mocktail.dart'; import 'package:privacy_gui/core/usp/providers/sse_invalidation_provider.dart'; import 'package:privacy_gui/core/usp/providers/usp_client_provider.dart'; import 'package:privacy_gui/core/usp/services/usp_client.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_network.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; import 'package:privacy_gui/page/_shared/models/system_info_ui_model.dart'; import 'package:privacy_gui/page/_shared/models/wifi_client_ui_model.dart'; import 'package:privacy_gui/page/_shared/models/mesh_topology_info.dart'; @@ -16,7 +18,6 @@ import 'package:privacy_gui/page/_shared/models/client_connection_detail.dart'; import 'package:privacy_gui/page/admin/providers/system_info_data_provider.dart'; import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; import 'package:privacy_gui/page/devices/services/usp_devices_data_service.dart'; -import 'package:privacy_gui/page/topology/models/node_ui_model.dart'; import 'package:privacy_gui/page/wifi_settings/providers/wifi_data_provider.dart'; class MockUspClient extends Mock implements UspClient {} @@ -27,42 +28,43 @@ void main() { late MockUspClient mockUsp; late MockUspDevicesDataService mockDevicesSvc; - final sampleDeviceModels = [ - DeviceUIModel( + final sampleClients = [ + ClientDevice( mac: 'AA:BB:CC:DD:EE:01', ip: '192.168.1.101', hostName: 'MyLaptop', isActive: true, - isWifi: true, + connectionType: ConnectionType.wifi, ), - DeviceUIModel( + ClientDevice( mac: 'AA:BB:CC:DD:EE:02', ip: '192.168.1.102', hostName: '', isActive: true, - isWifi: false, + connectionType: ConnectionType.wired, ), ]; - final sampleNodeModels = [ - NodeUIModel( - deviceId: 'gateway', + final sampleMeshNetwork = MeshNetwork( + master: MasterNode( + deviceId: 'GATEWAY', model: 'M60TB', - isMaster: true, + connectedClients: sampleClients, ), - ]; - - final sampleFetchResult = DevicesDataFetchResult( - codegenContext: DevicesCodegenContext.empty, - deviceModels: sampleDeviceModels, - nodeModels: [], - hostNameByMac: {'AA:BB:CC:DD:EE:01': 'MyLaptop'}, ); + late DevicesDataFetchResult sampleFetchResult; + setUp(() { mockUsp = MockUspClient(); mockDevicesSvc = MockUspDevicesDataService(); + sampleFetchResult = DevicesDataFetchResult( + codegenContext: DevicesCodegenContext.empty, + hostNameByMac: {'AA:BB:CC:DD:EE:01': 'MyLaptop'}, + meshNetwork: sampleMeshNetwork, + ); + when(() => mockDevicesSvc.fetch( wifiClientMap: any(named: 'wifiClientMap'), connectionDetailMap: any(named: 'connectionDetailMap'), @@ -80,9 +82,16 @@ void main() { meshTopology: any(named: 'meshTopology'), gatewayName: any(named: 'gatewayName'), systemInfo: any(named: 'systemInfo'), - )).thenReturn( - (deviceModels: sampleDeviceModels, nodeModels: sampleNodeModels), - ); + )).thenReturn(sampleMeshNetwork); + + when(() => mockDevicesSvc.rebuildWithMesh( + context: any(named: 'context'), + wifiClientMap: any(named: 'wifiClientMap'), + connectionDetailMap: any(named: 'connectionDetailMap'), + meshTopology: any(named: 'meshTopology'), + gatewayName: any(named: 'gatewayName'), + systemInfo: any(named: 'systemInfo'), + )).thenReturn(sampleMeshNetwork); }); setUpAll(() { @@ -104,7 +113,7 @@ void main() { )); registerFallbackValue({}); registerFallbackValue({}); - registerFallbackValue([]); + registerFallbackValue([]); }); ProviderContainer createContainer({ @@ -127,8 +136,8 @@ void main() { final container = createContainer(); final data = await container.read(devicesDataProvider.future); - expect(data.deviceModels, hasLength(2)); - expect(data.nodeModels, isEmpty); + expect(data.clientDevices, hasLength(2)); + expect(data.nodes, hasLength(1)); verify(() => mockDevicesSvc.fetch( wifiClientMap: any(named: 'wifiClientMap'), connectionDetailMap: any(named: 'connectionDetailMap'), @@ -179,7 +188,7 @@ void main() { ); final data = await container.read(devicesDataProvider.future); - expect(data.deviceModels, isNotEmpty); + expect(data.clientDevices, isNotEmpty); verify(() => mockDevicesSvc.fetch( wifiClientMap: any(named: 'wifiClientMap'), @@ -191,20 +200,37 @@ void main() { }); test('DevicesData copyWith works', () { - const data = DevicesData(); + final data = DevicesData( + meshNetwork: MeshNetwork( + master: MasterNode(deviceId: 'GATEWAY', model: 'Test'), + ), + ); final updated = data.copyWith( hostNameByMac: {'AA:BB': 'Test'}, ); expect(updated.hostNameByMac, {'AA:BB': 'Test'}); - expect(updated.deviceModels, isEmpty); + expect(updated.clientDevices, isEmpty); }); test('DevicesData props for equality', () { - const a = DevicesData(); - const b = DevicesData(); + final a = DevicesData( + meshNetwork: MeshNetwork( + master: MasterNode(deviceId: 'GATEWAY', model: 'Test'), + ), + ); + final b = DevicesData( + meshNetwork: MeshNetwork( + master: MasterNode(deviceId: 'GATEWAY', model: 'Test'), + ), + ); expect(a, equals(b)); - final c = DevicesData(hostNameByMac: {'AA:BB': 'Test'}); + final c = DevicesData( + meshNetwork: MeshNetwork( + master: MasterNode(deviceId: 'GATEWAY', model: 'Test'), + ), + hostNameByMac: {'AA:BB': 'Test'}, + ); expect(a, isNot(equals(c))); }); diff --git a/test/page/devices/services/usp_devices_data_service_test.dart b/test/page/devices/services/usp_devices_data_service_test.dart index 0a824886e..e6f443089 100644 --- a/test/page/devices/services/usp_devices_data_service_test.dart +++ b/test/page/devices/services/usp_devices_data_service_test.dart @@ -2,11 +2,11 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/core/usp/services/usp_client.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; import 'package:privacy_gui/page/_shared/models/system_info_ui_model.dart'; import 'package:privacy_gui/page/_shared/models/wifi_client_ui_model.dart'; import 'package:privacy_gui/page/_shared/models/mesh_topology_info.dart'; import 'package:privacy_gui/page/devices/services/usp_devices_data_service.dart'; -import 'package:privacy_gui/page/topology/models/node_ui_model.dart'; class MockUspClient extends Mock implements UspClient {} @@ -67,7 +67,7 @@ void main() { systemInfo: _sysInfo, ); - expect(result.deviceModels, hasLength(2)); + expect(result.meshNetwork.allClients, hasLength(2)); expect(result.codegenContext, isNot(DevicesCodegenContext.empty)); }); @@ -89,10 +89,10 @@ void main() { gatewayName: 'Router', ); - final wifi = - result.deviceModels.firstWhere((d) => d.mac == 'AA:BB:CC:DD:EE:01'); - final wired = - result.deviceModels.firstWhere((d) => d.mac == 'AA:BB:CC:DD:EE:02'); + final wifi = result.meshNetwork.allClients + .firstWhere((d) => d.mac == 'AA:BB:CC:DD:EE:01'); + final wired = result.meshNetwork.allClients + .firstWhere((d) => d.mac == 'AA:BB:CC:DD:EE:02'); expect(wifi.isWifi, isTrue); expect(wired.isWifi, isFalse); }); @@ -106,9 +106,9 @@ void main() { ); // Empty mesh → synthetic gateway node - expect(result.nodeModels, hasLength(1)); - expect(result.nodeModels.first.deviceId, 'gateway'); - expect(result.nodeModels.first.model, 'M60TB'); + expect(result.meshNetwork.allNodes, hasLength(1)); + expect(result.meshNetwork.master.deviceId, 'GATEWAY'); + expect(result.meshNetwork.master.model, 'M60TB'); }); test('skips node models when systemInfo is null', () async { @@ -119,7 +119,8 @@ void main() { systemInfo: null, ); - expect(result.nodeModels, isEmpty); + // Without systemInfo, still has a master node with default values + expect(result.meshNetwork.allNodes, hasLength(1)); }); test('maps USP error to ServiceError', () async { @@ -167,7 +168,7 @@ void main() { ); final wifi = - rebuilt.deviceModels.firstWhere((d) => d.mac == 'AA:BB:CC:DD:EE:01'); + rebuilt.allClients.firstWhere((d) => d.mac == 'AA:BB:CC:DD:EE:01'); expect(wifi.signalStrength, -50); expect(wifi.downlinkRate, 100); }); @@ -182,11 +183,11 @@ void main() { systemInfo: _sysInfo, ); - const mesh = MeshTopologyInfo( + final mesh = MeshTopologyInfo( nodes: [ - NodeUIModel(deviceId: 'NODE-A', model: 'M60'), + MasterNode(deviceId: 'NODE-A', model: 'M60'), ], - clientToNodeMap: {'AA:BB:CC:DD:EE:01': 'NODE-A'}, + clientToNodeMap: const {'AA:BB:CC:DD:EE:01': 'NODE-A'}, ); final rebuilt = svc.rebuildWithMesh( @@ -199,12 +200,12 @@ void main() { ); final wifi = - rebuilt.deviceModels.firstWhere((d) => d.mac == 'AA:BB:CC:DD:EE:01'); + rebuilt.allClients.firstWhere((d) => d.mac == 'AA:BB:CC:DD:EE:01'); expect(wifi.parentNodeId, 'NODE-A'); // Node models should reflect mesh - expect(rebuilt.nodeModels, hasLength(1)); - expect(rebuilt.nodeModels.first.isMaster, isTrue); + expect(rebuilt.allNodes, hasLength(1)); + expect(rebuilt.master.isMaster, isTrue); }); }); @@ -238,9 +239,9 @@ void main() { ); // Should be merged into 1 device - expect(result.deviceModels, hasLength(1)); - expect(result.deviceModels.first.hasMultipleInterfaces, isTrue); - expect(result.deviceModels.first.interfaceCount, 2); + expect(result.meshNetwork.allClients, hasLength(1)); + expect(result.meshNetwork.allClients.first.hasMultipleInterfaces, isTrue); + expect(result.meshNetwork.allClients.first.interfaceCount, 2); }); test('merged device includes all MAC addresses', () async { @@ -266,7 +267,7 @@ void main() { systemInfo: _sysInfo, ); - final device = result.deviceModels.first; + final device = result.meshNetwork.allClients.first; expect(device.allMacAddresses, hasLength(2)); expect( device.allMacAddresses, @@ -298,9 +299,10 @@ void main() { ); // Should remain as 2 separate devices - expect(result.deviceModels, hasLength(2)); - expect(result.deviceModels.first.hasMultipleInterfaces, isFalse); - expect(result.deviceModels.last.hasMultipleInterfaces, isFalse); + expect(result.meshNetwork.allClients, hasLength(2)); + expect( + result.meshNetwork.allClients.first.hasMultipleInterfaces, isFalse); + expect(result.meshNetwork.allClients.last.hasMultipleInterfaces, isFalse); }); test('mesh nodes (master/slave) are not merged', () async { @@ -328,15 +330,12 @@ void main() { systemInfo: _sysInfo, ); - // Mesh nodes should not be merged even with same hostname - // They should be excluded from deviceModels (client-only) or remain separate - final clientDevices = - result.deviceModels.where((d) => d.isClientDevice).toList(); - final meshDevices = - result.deviceModels.where((d) => !d.isClientDevice).toList(); + // Mesh nodes are separate from client devices in MeshNetwork + // allClients contains only client devices, allNodes contains mesh nodes + final clientDevices = result.meshNetwork.allClients; - // Mesh devices are not merged - expect(meshDevices, hasLength(2)); + // Mesh nodes with DeviceRole are filtered out from clients + // The test data has 2 devices both with mesh roles, so no client devices expect(clientDevices, isEmpty); }); @@ -364,7 +363,7 @@ void main() { systemInfo: _sysInfo, ); - final device = result.deviceModels.first; + final device = result.meshNetwork.allClients.first; // WiFi interface should be primary (isWifi=true) expect(device.isWifi, isTrue); expect(device.mac, 'AA:BB:CC:DD:EE:01'); // WiFi MAC @@ -393,7 +392,7 @@ void main() { systemInfo: _sysInfo, ); - final device = result.deviceModels.first; + final device = result.meshNetwork.allClients.first; expect(device.additionalInterfaces, hasLength(1)); final secondary = device.additionalInterfaces.first; @@ -427,9 +426,9 @@ void main() { ); // Should merge into 1 device despite different case - expect(result.deviceModels, hasLength(1)); - expect(result.deviceModels.first.hasMultipleInterfaces, isTrue); - expect(result.deviceModels.first.interfaceCount, 2); + expect(result.meshNetwork.allClients, hasLength(1)); + expect(result.meshNetwork.allClients.first.hasMultipleInterfaces, isTrue); + expect(result.meshNetwork.allClients.first.interfaceCount, 2); }); test('devices with mDNS suffix hostname merge correctly', () async { @@ -457,9 +456,9 @@ void main() { ); // Should merge: "MacBook._tcp.local" → "macbook", "MacBook" → "macbook" - expect(result.deviceModels, hasLength(1)); - expect(result.deviceModels.first.hasMultipleInterfaces, isTrue); - expect(result.deviceModels.first.interfaceCount, 2); + expect(result.meshNetwork.allClients, hasLength(1)); + expect(result.meshNetwork.allClients.first.hasMultipleInterfaces, isTrue); + expect(result.meshNetwork.allClients.first.interfaceCount, 2); }); test('inactive WiFi + active Ethernet selects Ethernet as primary', @@ -487,7 +486,7 @@ void main() { systemInfo: _sysInfo, ); - final device = result.deviceModels.first; + final device = result.meshNetwork.allClients.first; // Ethernet should be primary because it's active (active > WiFi preference) expect(device.isWifi, isFalse); expect(device.mac, 'AA:BB:CC:DD:EE:02'); // Ethernet MAC diff --git a/test/page/dhcp/providers/usp_dhcp_reservations_notifier_test.dart b/test/page/dhcp/providers/usp_dhcp_reservations_notifier_test.dart index 4d52f50f2..c92f585cf 100644 --- a/test/page/dhcp/providers/usp_dhcp_reservations_notifier_test.dart +++ b/test/page/dhcp/providers/usp_dhcp_reservations_notifier_test.dart @@ -3,7 +3,12 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/core/usp/providers/usp_mutation_lock.dart'; +import 'package:privacy_gui/page/_shared/models/backhaul_info.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; import 'package:privacy_gui/page/_shared/models/dhcp_reservation_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_network.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; +import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; import 'package:privacy_gui/page/dhcp/providers/usp_dhcp_reservations_notifier.dart'; import 'package:privacy_gui/page/dhcp/services/usp_dhcp_service.dart'; import 'package:privacy_gui/page/local_network/providers/dhcp_data_provider.dart'; @@ -354,9 +359,170 @@ void main() { ); container.dispose(); }); + + // ------------------------------------------------------------------------- + // deviceOptions (autocomplete data source) + // ------------------------------------------------------------------------- + + ProviderContainer createContainerWithDevices(DevicesData devicesData) { + final container = ProviderContainer( + overrides: [ + uspDhcpServiceProvider.overrideWithValue(mockService), + uspMutationLockProvider.overrideWithValue(UspMutationLock()), + devicesDataProvider + .overrideWith(() => _TestDevicesDataNotifier(devicesData)), + ], + ); + container.listen(uspDhcpReservationsProvider, (_, __) {}); + return container; + } + + test('deviceOptions maps client devices to pure data', () async { + when(() => mockService.fetchReservations()).thenAnswer((_) async => []); + final container = createContainerWithDevices(_devicesData( + clients: [ + _device( + mac: 'AA:BB:CC:DD:EE:01', + ip: '192.168.1.10', + friendlyName: 'Laptop', + isActive: true, + ), + ], + )); + // Ensure devicesDataProvider resolves before reading. + await container.read(devicesDataProvider.future); + await Future.delayed(Duration.zero); + + final options = + container.read(uspDhcpReservationsProvider.notifier).deviceOptions(); + + expect(options, hasLength(1)); + expect(options[0].name, 'Laptop'); + expect(options[0].mac, 'AA:BB:CC:DD:EE:01'); + expect(options[0].ip, '192.168.1.10'); + expect(options[0].isActive, isTrue); + container.dispose(); + }); + + test('deviceOptions name falls back to hostName then mac', () async { + when(() => mockService.fetchReservations()).thenAnswer((_) async => []); + final container = createContainerWithDevices(_devicesData( + clients: [ + _device(mac: 'AA:BB:CC:DD:EE:01', ip: '192.168.1.10', hostName: 'pc'), + _device(mac: 'AA:BB:CC:DD:EE:02', ip: '192.168.1.11'), + ], + )); + await container.read(devicesDataProvider.future); + await Future.delayed(Duration.zero); + + final options = + container.read(uspDhcpReservationsProvider.notifier).deviceOptions(); + + expect(options[0].name, 'pc'); + expect(options[1].name, 'AA:BB:CC:DD:EE:02'); + container.dispose(); + }); + + test('deviceOptions excludes mesh nodes (master/slave)', () async { + when(() => mockService.fetchReservations()).thenAnswer((_) async => []); + // Clients (on master OR on a slave) surface as options; the mesh nodes + // themselves (master/slave NodeEntity identities) must NOT. Exclusion is + // structural: nodes live in MeshNetwork.master/.slaves, never in any + // node's connectedClients, so they can never leak into clientDevices. + const slaveNodeMac = 'AA:BB:CC:DD:EE:03'; // the slave NODE's own id + final container = createContainerWithDevices(_devicesData( + clients: [ + _device( + mac: 'AA:BB:CC:DD:EE:01', ip: '192.168.1.10'), // master client + ], + slaves: [ + SlaveNode( + deviceId: slaveNodeMac, + model: 'TestExtender', + backhaul: const BackhaulInfo(mediaType: 'Wi-Fi'), + // A client connected TO the slave — this IS a client and must surface. + connectedClients: [ + _device(mac: 'AA:BB:CC:DD:EE:02', ip: '192.168.1.20'), + ], + ), + ], + )); + await container.read(devicesDataProvider.future); + await Future.delayed(Duration.zero); + + final options = + container.read(uspDhcpReservationsProvider.notifier).deviceOptions(); + + final optionMacs = options.map((o) => o.mac).toSet(); + // Both the master client and the slave-connected client surface... + expect(optionMacs, {'AA:BB:CC:DD:EE:01', 'AA:BB:CC:DD:EE:02'}); + // ...but the slave NODE's own identity must never appear as an option. + expect(optionMacs, isNot(contains(slaveNodeMac))); + container.dispose(); + }); + + test('deviceOptions returns empty when no devices', () async { + when(() => mockService.fetchReservations()).thenAnswer((_) async => []); + final container = createContainerWithDevices(_devicesData()); + await container.read(devicesDataProvider.future); + await Future.delayed(Duration.zero); + + final options = + container.read(uspDhcpReservationsProvider.notifier).deviceOptions(); + + expect(options, isEmpty); + container.dispose(); + }); }); } +/// Test notifier that returns a fixed [DevicesData]. +class _TestDevicesDataNotifier extends DevicesDataNotifier { + final DevicesData _data; + + _TestDevicesDataNotifier(this._data); + + @override + Future build() async => _data; +} + +ClientDevice _device({ + required String mac, + required String ip, + String hostName = '', + String? friendlyName, + bool isActive = true, +}) { + return ClientDevice( + mac: mac, + ip: ip, + hostName: hostName, + isActive: isActive, + connectionType: ConnectionType.wired, + friendlyName: friendlyName, + ); +} + +/// Wraps client devices (and optional mesh nodes) into a [DevicesData] backed +/// by a [MeshNetwork]. Client devices are attached to the master node so they +/// surface via `devicesData.clientDevices`; mesh nodes are NOT clients and thus +/// are never offered as reservation options. +DevicesData _devicesData({ + List clients = const [], + List slaves = const [], +}) { + return DevicesData( + meshNetwork: MeshNetwork( + master: MasterNode( + deviceId: 'GATEWAY', + model: 'TestRouter', + connectedClients: clients, + ), + slaves: slaves, + ), + ); +} + /// Test notifier that tracks invalidation. class _TestDhcpDataNotifier extends DhcpDataNotifier { final void Function()? onInvalidate; diff --git a/test/page/dhcp/views/dialogs/dhcp_reservation_edit_dialog_test.dart b/test/page/dhcp/views/dialogs/dhcp_reservation_edit_dialog_test.dart new file mode 100644 index 000000000..ea2075ec5 --- /dev/null +++ b/test/page/dhcp/views/dialogs/dhcp_reservation_edit_dialog_test.dart @@ -0,0 +1,225 @@ +@Tags(['ui']) +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:ui_kit_library/ui_kit.dart'; +import 'package:privacy_gui/l10n/gen/app_localizations.dart'; +import 'package:privacy_gui/page/_shared/models/dhcp_reservation_ui_model.dart'; +import 'package:privacy_gui/page/dhcp/views/dialogs/dhcp_reservation_edit_dialog.dart'; + +/// Widget tests for [DhcpReservationEditDialog] duplicate-address validation. +/// +/// Covers linksys/PrivacyGUI#1070: the Add/Edit dialog must reject a +/// reservation whose MAC or IP already exists among the current reservations, +/// while still allowing the reservation being edited to keep its own address. +/// +/// The dialog surfaces validation errors via the two [AppTextField]s' +/// `errorText`. AppTextField renders that error inline OR as a focus tooltip +/// depending on layout/focus state, so these tests assert on the widget's +/// `errorText` property (the source of truth) rather than a rendered string, +/// which is both more robust and independent of ui_kit rendering internals. +final _testTheme = AppTheme.create( + brightness: Brightness.light, + seedColor: Colors.blue, + designThemeBuilder: (c) => CustomDesignTheme.fromJson({'style': 'flat'}), +); + +final _existing = [ + DhcpReservationUIModel( + mac: 'AA:BB:CC:DD:EE:01', ip: '192.168.1.10', enable: true), + DhcpReservationUIModel( + mac: 'AA:BB:CC:DD:EE:02', ip: '192.168.1.11', enable: true), +]; + +/// Pumps the dialog inside a GoRouter (the dialog calls context.pop) and +/// immediately opens it. Returns the record the dialog pops with (null = still +/// open / cancelled). +Future<({String mac, String ip, bool enable})?> _pumpAndOpen( + WidgetTester tester, { + DhcpReservationUIModel? reservation, + List? existing, +}) async { + final existingReservations = existing ?? _existing; + ({String mac, String ip, bool enable})? result; + bool popped = false; + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (context, state) => Scaffold( + body: Builder( + builder: (context) => Center( + child: ElevatedButton( + onPressed: () async { + result = await showAppDialog< + ({String mac, String ip, bool enable})>( + context: context, + builder: (_) => DhcpReservationEditDialog( + reservation: reservation, + existingReservations: existingReservations, + ), + ); + popped = true; + }, + child: const Text('open'), + ), + ), + ), + ), + ), + ], + ); + + await tester.pumpWidget(MaterialApp.router( + theme: _testTheme, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + routerConfig: router, + )); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + return popped ? result : null; +} + +/// The two AppTextFields are ordered MAC (0), IP (1) in the dialog. +AppTextField _macField() => + find.byType(AppTextField).evaluate().elementAt(0).widget as AppTextField; +AppTextField _ipField() => + find.byType(AppTextField).evaluate().elementAt(1).widget as AppTextField; + +Future _enterMac(WidgetTester tester, String text) async { + await tester.enterText(find.byType(TextField).at(0), text); + await tester.pumpAndSettle(); + // The dialog validates on blur (FocusNode listener), not on every keystroke. + // Drop focus to trigger _validate(), mirroring a real user tabbing away. + FocusManager.instance.primaryFocus?.unfocus(); + await tester.pumpAndSettle(); +} + +Future _enterIp(WidgetTester tester, String text) async { + await tester.enterText(find.byType(TextField).at(1), text); + await tester.pumpAndSettle(); + // The dialog validates on blur (FocusNode listener), not on every keystroke. + // Drop focus to trigger _validate(), mirroring a real user tabbing away. + FocusManager.instance.primaryFocus?.unfocus(); + await tester.pumpAndSettle(); +} + +void main() { + late String dupMac; + late String dupIp; + late String addLabel; + + setUp(() async { + final loc = await AppLocalizations.delegate.load(const Locale('en')); + dupMac = loc.duplicateMacAddress; + dupIp = loc.duplicateIpAddress; + addLabel = loc.add; + }); + + group('DhcpReservationEditDialog duplicate validation (add)', () { + testWidgets('duplicate MAC sets error and keeps Add disabled', + (tester) async { + await _pumpAndOpen(tester); + await _enterMac(tester, 'AA:BB:CC:DD:EE:01'); // dup of existing + await _enterIp(tester, '192.168.1.99'); // unique IP + + expect(_macField().errorText, dupMac); + expect(_ipField().errorText, isNull); + + // Add stays disabled -> tapping does not pop the dialog. + await tester.tap(find.widgetWithText(AppButton, addLabel)); + await tester.pumpAndSettle(); + expect(find.byType(DhcpReservationEditDialog), findsOneWidget); + }); + + testWidgets('duplicate IP sets error and keeps Add disabled', + (tester) async { + await _pumpAndOpen(tester); + await _enterMac(tester, 'AA:BB:CC:DD:EE:99'); // unique MAC + await _enterIp(tester, '192.168.1.10'); // dup of existing + + expect(_ipField().errorText, dupIp); + expect(_macField().errorText, isNull); + + await tester.tap(find.widgetWithText(AppButton, addLabel)); + await tester.pumpAndSettle(); + expect(find.byType(DhcpReservationEditDialog), findsOneWidget); + }); + + testWidgets('duplicate MAC match is case-insensitive', (tester) async { + await _pumpAndOpen(tester); + await _enterMac(tester, 'aa:bb:cc:dd:ee:01'); // lowercase dup + await _enterIp(tester, '192.168.1.99'); + + expect(_macField().errorText, dupMac); + }); + + testWidgets('fully unique MAC+IP has no duplicate error', (tester) async { + await _pumpAndOpen(tester); + await _enterMac(tester, 'AA:BB:CC:DD:EE:99'); + await _enterIp(tester, '192.168.1.99'); + + expect(_macField().errorText, isNull); + expect(_ipField().errorText, isNull); + }); + }); + + group('DhcpReservationEditDialog duplicate validation (edit)', () { + testWidgets('editing keeps own MAC/IP without a duplicate error', + (tester) async { + // Open in edit mode on the first reservation; its pre-filled own + // MAC/IP must not be flagged as a duplicate of itself. + await _pumpAndOpen(tester, reservation: _existing[0]); + // Re-enter its own values to force _validate() to run. + await _enterMac(tester, _existing[0].mac); + await _enterIp(tester, _existing[0].ip); + + expect(_macField().errorText, isNull); + expect(_ipField().errorText, isNull); + }); + + testWidgets('editing to another existing MAC flags duplicate', + (tester) async { + await _pumpAndOpen(tester, reservation: _existing[0]); + await _enterMac(tester, _existing[1].mac); // collide with the OTHER one + + expect(_macField().errorText, dupMac); + }); + + testWidgets( + 'editing own reservation is not a false-positive duplicate when its ' + 'non-key field drifts in existingReservations (SSE race)', + (tester) async { + // Reproduces the SSE-race false-positive: while the edit dialog is open, + // the Notifier re-fetches and toggles the edited reservation's `enable` + // flag in existingReservations. Because DhcpReservationUIModel uses + // value-equality (props include `enable`), a value-equality self-filter + // would fail to exclude self and flag the user's own MAC/IP as a + // duplicate. Self must be excluded by stable instancePath identity. + final frozen = DhcpReservationUIModel( + instancePath: 'Device.DHCPv4.Server.Pool.1.StaticAddress.1', + mac: 'AA:BB:CC:DD:EE:01', + ip: '192.168.1.10', + enable: true, + ); + // Same instancePath, but enable drifted true -> false (SSE update). + final drifted = DhcpReservationUIModel( + instancePath: 'Device.DHCPv4.Server.Pool.1.StaticAddress.1', + mac: 'AA:BB:CC:DD:EE:01', + ip: '192.168.1.10', + enable: false, + ); + await _pumpAndOpen(tester, reservation: frozen, existing: [drifted]); + // Change the value (so onChanged fires) then set it back to its own MAC, + // forcing _validate() to run against the drifted self-entry. + await _enterMac(tester, 'AA:BB:CC:DD:EE:09'); + await _enterMac(tester, frozen.mac); + await _enterIp(tester, '192.168.1.99'); + await _enterIp(tester, frozen.ip); + + expect(_macField().errorText, isNull); + expect(_ipField().errorText, isNull); + }); + }); +} diff --git a/test/page/dmz/providers/usp_dmz_notifier_test.dart b/test/page/dmz/providers/usp_dmz_notifier_test.dart index 9ff8d9a96..b1352e46f 100644 --- a/test/page/dmz/providers/usp_dmz_notifier_test.dart +++ b/test/page/dmz/providers/usp_dmz_notifier_test.dart @@ -149,10 +149,9 @@ void main() { container.dispose(); }); - test('updateSetting mutates current model and validates', () async { + test('updateSetting mutates current model without validation', () async { when(() => mockService.fetch()) .thenAnswer((_) async => (testSettings, testStatus)); - when(() => mockService.validateForm(any())).thenReturn({}); final container = createContainer(); await Future.delayed(Duration.zero); @@ -162,11 +161,12 @@ void main() { final state = container.read(uspDmzProvider); expect(state.settings.current.model.destIp, '10.0.0.1'); - verify(() => mockService.validateForm(any())).called(1); + // updateSetting no longer calls validateForm — validation is separate + verifyNever(() => mockService.validateForm(any())); container.dispose(); }); - test('updateSetting propagates validation errors to status', () async { + test('validate propagates validation errors to status', () async { when(() => mockService.fetch()) .thenAnswer((_) async => (testSettings, testStatus)); when(() => mockService.validateForm(any())) @@ -177,6 +177,7 @@ void main() { final notifier = container.read(uspDmzProvider.notifier); notifier.updateSetting((m) => m.copyWith(destIp: 'bad')); + notifier.validate(); final state = container.read(uspDmzProvider); expect(state.status.fieldErrors['destIp'], 'Invalid IP'); diff --git a/test/page/firewall/providers/usp_firewall_notifier_test.dart b/test/page/firewall/providers/usp_firewall_notifier_test.dart index 3e1ae0e60..74c89dc5c 100644 --- a/test/page/firewall/providers/usp_firewall_notifier_test.dart +++ b/test/page/firewall/providers/usp_firewall_notifier_test.dart @@ -189,4 +189,53 @@ void main() { container.dispose(); }); }); + + group('FirewallData equality', () { + FirewallData dataWith({ + List ruleSummaries = const [], + FirewallRuleContext ruleContext = FirewallRuleContext.empty, + List dmzSummaries = const [], + }) => + FirewallData( + firewallModel: const FirewallUIModel(), + ruleContext: ruleContext, + ruleSummaries: ruleSummaries, + dmzModel: const DmzUIModel.disabled(), + dmzSummaries: dmzSummaries, + ); + + test('differs when rule content changes but count stays the same', () { + // Regression: props must not narrow to ruleSummaries.length. Two rules of + // equal length but different content are NOT equal, so the provider still + // notifies listeners on a content-only change. + final a = dataWith(ruleSummaries: const [ + FirewallRuleSummary(target: 'ACCEPT', enabled: true), + ]); + final b = dataWith(ruleSummaries: const [ + FirewallRuleSummary(target: 'DROP', enabled: true), + ]); + + expect(a, isNot(equals(b))); + }); + + test('differs when only dmzSummaries changes', () { + final a = dataWith(dmzSummaries: const []); + final b = dataWith(dmzSummaries: const [ + DmzEntrySummary(enable: true, destIp: '192.168.1.5'), + ]); + + expect(a, isNot(equals(b))); + }); + + test('namedProps stays lean for diagnostic output', () { + final data = dataWith(ruleSummaries: const [ + FirewallRuleSummary(target: 'ACCEPT', enabled: true), + FirewallRuleSummary(target: 'DROP', enabled: false), + ]); + + expect(data.namedProps.keys, + containsAll(['firewallModel', 'ruleCount', 'dmzModel'])); + expect(data.namedProps['ruleCount'], 2); + }); + }); } diff --git a/test/page/firmware_update/providers/firmware_update_notifier_test.dart b/test/page/firmware_update/providers/firmware_update_notifier_test.dart index 22a4d43bb..0366234eb 100644 --- a/test/page/firmware_update/providers/firmware_update_notifier_test.dart +++ b/test/page/firmware_update/providers/firmware_update_notifier_test.dart @@ -44,8 +44,9 @@ class MockAuthNotifier extends AsyncNotifier with Mock implements AuthNotifier { @override - Future build() async => - AuthState(loginType: LoginType.local, localPassword: 'test'); + Future build() async => AuthState( + loginType: LoginType.local, + ); } class _StubPickerService extends FirmwareFilePickerService { diff --git a/test/page/instant_privacy/services/usp_instant_privacy_service_test.dart b/test/page/instant_privacy/services/usp_instant_privacy_service_test.dart index 4adc6fb6c..3e3bba84a 100644 --- a/test/page/instant_privacy/services/usp_instant_privacy_service_test.dart +++ b/test/page/instant_privacy/services/usp_instant_privacy_service_test.dart @@ -162,6 +162,28 @@ void main() { final result = service.activeDevices(ConnectedDevices(items: [])); expect(result, isEmpty); }); + + test('flags locally-administered (private) MAC as isPrivateMac', () { + // Second hex digit 2/6/A/E → U/L bit set → private/randomized MAC. + final data = ConnectedDevices(items: [ + _device(macAddress: '2E:52:AD:77:D0:F8'), + ]); + + final result = service.activeDevices(data); + + expect(result[0].isPrivateMac, isTrue); + }); + + test('does not flag a universally-administered (real) MAC', () { + // 74 → U/L bit clear → real hardware MAC. + final data = ConnectedDevices(items: [ + _device(macAddress: '74:12:13:21:56:3B'), + ]); + + final result = service.activeDevices(data); + + expect(result[0].isPrivateMac, isFalse); + }); }); // --------------------------------------------------------------------------- @@ -407,6 +429,24 @@ void main() { // FF:FF:FF:FF:FF:FF is not in connected devices → MAC address as display name expect(result.allowedDevices[0].displayName, 'FF:FF:FF:FF:FF:FF'); }); + + test('allowed devices have isPrivateMac flag set correctly', () async { + stubFetchAll(mockUsp, apResponse: { + 'Device.WiFi.AccessPoint.1.SSIDReference': 'Device.WiFi.SSID.1', + 'Device.WiFi.AccessPoint.1.MACAddressControlEnabled': true, + // 2E:... is locally-administered (private), 74:... is universal (real) + 'Device.WiFi.AccessPoint.1.AllowedMACAddress': + '2E:52:AD:77:D0:F8,74:12:13:21:56:3B', + }); + + final result = await service.fetchAll(); + + expect(result.allowedDevices, hasLength(2)); + expect(result.allowedDevices[0].mac, '2E:52:AD:77:D0:F8'); + expect(result.allowedDevices[0].isPrivateMac, isTrue); + expect(result.allowedDevices[1].mac, '74:12:13:21:56:3B'); + expect(result.allowedDevices[1].isPrivateMac, isFalse); + }); }); group('UspInstantPrivacyService — enable', () { diff --git a/test/page/instant_setup/providers/pnp_notifier_test.dart b/test/page/instant_setup/providers/pnp_notifier_test.dart index e435b69b0..bdfb1fd94 100644 --- a/test/page/instant_setup/providers/pnp_notifier_test.dart +++ b/test/page/instant_setup/providers/pnp_notifier_test.dart @@ -1,6 +1,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/core/usp/providers/usp_mutation_lock.dart'; import 'package:privacy_gui/core/usp/providers/usp_client_provider.dart'; import 'package:privacy_gui/core/usp/services/usp_client.dart'; @@ -127,9 +128,32 @@ void main() { container.dispose(); }); - test('error transitions to AdminError phase', () async { + test('device info read failure transitions to AdminReadFailure phase', + () async { + when(() => mockPnpService.checkFactoryDefault()) + .thenThrow(const NetworkError(detail: 'Network error')); + + final container = createContainer(); + final notifier = container.read(pnpProvider.notifier); + + await notifier.startPostLoginFlow(); + + final state = container.read(pnpProvider); + expect(state.phase, isA()); + expect( + (state.phase as AdminReadFailure).detail, contains('Network error')); + container.dispose(); + }); + + // Regression for #1098: a WAN read FAILURE (USP GET returned empty → + // WanStatus.fetch throws) must NOT collapse into NoInternet. That state is + // reserved for the router *confirming* no internet (returns false, no throw). + test('WAN read failure transitions to AdminReadFailure, not NoInternet', + () async { when(() => mockPnpService.checkFactoryDefault()) - .thenThrow(Exception('Network error')); + .thenAnswer((_) async => testFactoryResult); + when(() => mockPnpService.checkInternetConnected()) + .thenThrow(const InvalidInputError(code: 9998, detail: 'missing')); final container = createContainer(); final notifier = container.read(pnpProvider.notifier); @@ -137,8 +161,8 @@ void main() { await notifier.startPostLoginFlow(); final state = container.read(pnpProvider); - expect(state.phase, isA()); - expect((state.phase as AdminError).message, contains('Network error')); + expect(state.phase, isA()); + expect((state.phase as AdminReadFailure).code, 9998); container.dispose(); }); }); @@ -334,5 +358,38 @@ void main() { expect(state.phase, isA()); container.dispose(); }); + + // Regression for #1098: the ISP save WRITE succeeds, but the trailing + // internet check READ fails (USP GET returned empty). This must land in + // AdminReadFailure (read failure), NOT NoInternet — distinct from a genuine + // no-internet (checkInternetConnected returns false) and from a save write + // failure (which stays on NoInternet + errorMessage, tested above). + test('ISP save success but check read failure → AdminReadFailure', + () async { + when(() => mockPnpService.saveIspSettings(any())) + .thenAnswer((_) async {}); + when(() => mockPnpService.checkInternetConnected()) + .thenThrow(const InvalidInputError(code: 9998, detail: 'missing')); + + final container = createContainer(); + final notifier = container.read(pnpProvider.notifier); + + notifier.setDemoPhase(const NoInternet(ssid: 'Test')); + + const config = PnpIspConfig( + type: IspConnectionType.staticIp, + staticIpAddress: '10.0.0.5', + subnetMask: '255.255.255.0', + defaultGateway: '10.0.0.1', + ); + + await notifier.saveIspWithProgress(config); + + verify(() => mockPnpService.saveIspSettings(any())).called(1); + final state = container.read(pnpProvider); + expect(state.phase, isA()); + expect((state.phase as AdminReadFailure).code, 9998); + container.dispose(); + }); }); } diff --git a/test/page/instant_setup/services/pnp_service_test.dart b/test/page/instant_setup/services/pnp_service_test.dart index 7208e13d5..52a8ba0aa 100644 --- a/test/page/instant_setup/services/pnp_service_test.dart +++ b/test/page/instant_setup/services/pnp_service_test.dart @@ -47,6 +47,7 @@ void main() { 'Device.PPP.Interface.1.IdleDisconnectTime': '0', 'Device.PPP.Interface.1.LCPEcho': '30', 'Device.PPP.Interface.1.ConnectionStatus': 'Connected', + 'Device.PPP.Interface.1.LowerLayers': 'Device.Ethernet.Link.2', }; const vlanExistingResponse = { @@ -92,6 +93,16 @@ void main() { if (paths.any((p) => p.contains('VLANTermination'))) { return vlanResponse; } + if (paths.any((p) => p.contains('GRE.Tunnel'))) { + return { + 'Device.GRE.Tunnel.1.RemoteEndpoints': '', + }; + } + if (paths.any((p) => p.contains('L2TPv2.Tunnel'))) { + return { + 'Device.L2TPv2.Tunnel.1.RemoteEndpoints': '', + }; + } return {}; }); } @@ -191,11 +202,14 @@ void main() { // - Ipv6Settings._resolveInstance() + fetch() = 2 calls // - PppInterface.fetch() = 1 call // - VlanTermination.fetch() = 1 call + // - GreTunnel.fetch() = 1 call + // - L2tpTunnel.fetch() = 1 call + // - _fetchHostName() (Device.DeviceInfo.HostName) = 1 call // saveAll: // - WanStaticIp.updateOrdered() → _resolveInstance() = 1 call // - Ipv6Settings.update() → _resolveInstance() = 1 call - // Total = 8 get calls - verify(() => mockUsp.get(any())).called(8); + // Total = 11 get calls + verify(() => mockUsp.get(any())).called(11); // Verify setOrdered was called for Static IP mode switch final capturedOrdered = verify(() => mockUsp.setOrdered(captureAny(), @@ -232,12 +246,13 @@ void main() { await service.saveIspSettings(config); // Verify fetchSettings + saveAll get calls: - // fetchSettings: 6 calls (WanSettings, Ipv6, PPP, VLAN) + // fetchSettings: 9 calls (WanSettings x2, Ipv6 x2, PPP, VLAN, GRE, L2TP, + // _fetchHostName = Device.DeviceInfo.HostName) // saveAll: // - WanPppoe.update() → _resolveInstance() = 1 call // - Ipv6Settings.update() → _resolveInstance() = 1 call - // Total = 8 get calls - verify(() => mockUsp.get(any())).called(8); + // Total = 11 get calls + verify(() => mockUsp.get(any())).called(11); // Verify PppInterface.add was called (new PPP instance created) final addCaptures = verify(() => mockUsp.add(captureAny())).captured; @@ -290,6 +305,16 @@ void main() { if (paths.any((p) => p.contains('VLANTermination'))) { return vlanExistingResponse; } + if (paths.any((p) => p.contains('GRE.Tunnel'))) { + return { + 'Device.GRE.Tunnel.1.RemoteEndpoints': '', + }; + } + if (paths.any((p) => p.contains('L2TPv2.Tunnel'))) { + return { + 'Device.L2TPv2.Tunnel.1.RemoteEndpoints': '', + }; + } return {}; }); setupSetMocks(); @@ -351,6 +376,16 @@ void main() { if (paths.any((p) => p.contains('VLANTermination'))) { return vlanDisabledResponse; } + if (paths.any((p) => p.contains('GRE.Tunnel'))) { + return { + 'Device.GRE.Tunnel.1.RemoteEndpoints': '', + }; + } + if (paths.any((p) => p.contains('L2TPv2.Tunnel'))) { + return { + 'Device.L2TPv2.Tunnel.1.RemoteEndpoints': '', + }; + } return {}; }); setupSetMocks(); @@ -378,4 +413,129 @@ void main() { verifyNever(() => mockUsp.add(any())); }); }); + + group('PnpService.fetchWizardData — guest detection via alias', () { + // Two radios, each with a main + guest SSID. Guest is identified purely by + // the `-guest` alias suffix (see wifi_guest_detection), NOT instance order. + const wifiSsidsResponse = { + 'Device.WiFi.SSID.1.SSID': 'MyHome', + 'Device.WiFi.SSID.1.Enable': true, + 'Device.WiFi.SSID.1.Status': 'Up', + 'Device.WiFi.SSID.1.BSSID': 'AA:BB:CC:DD:EE:01', + 'Device.WiFi.SSID.1.LowerLayers': 'Device.WiFi.Radio.1.', + 'Device.WiFi.SSID.1.Alias': 'wifi-2g', + 'Device.WiFi.SSID.2.SSID': 'MyHome', + 'Device.WiFi.SSID.2.Enable': true, + 'Device.WiFi.SSID.2.Status': 'Up', + 'Device.WiFi.SSID.2.BSSID': 'AA:BB:CC:DD:EE:02', + 'Device.WiFi.SSID.2.LowerLayers': 'Device.WiFi.Radio.2.', + 'Device.WiFi.SSID.2.Alias': 'wifi-5g', + 'Device.WiFi.SSID.3.SSID': 'MyHome-Guest', + 'Device.WiFi.SSID.3.Enable': false, + 'Device.WiFi.SSID.3.Status': 'Down', + 'Device.WiFi.SSID.3.BSSID': 'AA:BB:CC:DD:EE:03', + 'Device.WiFi.SSID.3.LowerLayers': 'Device.WiFi.Radio.1.', + 'Device.WiFi.SSID.3.Alias': 'wifi-2g-guest', + 'Device.WiFi.SSID.4.SSID': 'MyHome-Guest', + 'Device.WiFi.SSID.4.Enable': false, + 'Device.WiFi.SSID.4.Status': 'Down', + 'Device.WiFi.SSID.4.BSSID': 'AA:BB:CC:DD:EE:04', + 'Device.WiFi.SSID.4.LowerLayers': 'Device.WiFi.Radio.2.', + 'Device.WiFi.SSID.4.Alias': 'wifi-5g-guest', + }; + const wifiApsResponse = { + 'Device.WiFi.AccessPoint.1.Enable': true, + 'Device.WiFi.AccessPoint.1.Status': 'Enabled', + 'Device.WiFi.AccessPoint.1.Security.ModesSupported': + 'WPA2-Personal,WPA3-Personal', + 'Device.WiFi.AccessPoint.1.Security.ModeEnabled': 'WPA2-Personal', + 'Device.WiFi.AccessPoint.1.Security.EncryptionMode': 'AES', + 'Device.WiFi.AccessPoint.1.Security.KeyPassphrase': 'mainpass1', + 'Device.WiFi.AccessPoint.1.SSIDAdvertisementEnabled': true, + 'Device.WiFi.AccessPoint.1.SSIDReference': 'Device.WiFi.SSID.1.', + 'Device.WiFi.AccessPoint.2.Enable': true, + 'Device.WiFi.AccessPoint.2.Status': 'Enabled', + 'Device.WiFi.AccessPoint.2.Security.ModesSupported': + 'WPA2-Personal,WPA3-Personal', + 'Device.WiFi.AccessPoint.2.Security.ModeEnabled': 'WPA2-Personal', + 'Device.WiFi.AccessPoint.2.Security.EncryptionMode': 'AES', + 'Device.WiFi.AccessPoint.2.Security.KeyPassphrase': 'mainpass2', + 'Device.WiFi.AccessPoint.2.SSIDAdvertisementEnabled': true, + 'Device.WiFi.AccessPoint.2.SSIDReference': 'Device.WiFi.SSID.2.', + 'Device.WiFi.AccessPoint.3.Enable': false, + 'Device.WiFi.AccessPoint.3.Status': 'Disabled', + 'Device.WiFi.AccessPoint.3.Security.ModesSupported': '', + 'Device.WiFi.AccessPoint.3.Security.ModeEnabled': 'None', + 'Device.WiFi.AccessPoint.3.Security.EncryptionMode': 'None', + 'Device.WiFi.AccessPoint.3.Security.KeyPassphrase': 'guestpass1', + 'Device.WiFi.AccessPoint.3.SSIDAdvertisementEnabled': true, + 'Device.WiFi.AccessPoint.3.SSIDReference': 'Device.WiFi.SSID.3.', + 'Device.WiFi.AccessPoint.4.Enable': false, + 'Device.WiFi.AccessPoint.4.Status': 'Disabled', + 'Device.WiFi.AccessPoint.4.Security.ModesSupported': '', + 'Device.WiFi.AccessPoint.4.Security.ModeEnabled': 'None', + 'Device.WiFi.AccessPoint.4.Security.EncryptionMode': 'None', + 'Device.WiFi.AccessPoint.4.Security.KeyPassphrase': 'guestpass2', + 'Device.WiFi.AccessPoint.4.SSIDAdvertisementEnabled': true, + 'Device.WiFi.AccessPoint.4.SSIDReference': 'Device.WiFi.SSID.4.', + }; + Map radioFields(int i, String band, int channel) => { + 'Device.WiFi.Radio.$i.Enable': true, + 'Device.WiFi.Radio.$i.Status': 'Up', + 'Device.WiFi.Radio.$i.Channel': channel, + 'Device.WiFi.Radio.$i.OperatingFrequencyBand': band, + 'Device.WiFi.Radio.$i.OperatingChannelBandwidth': '20MHz', + 'Device.WiFi.Radio.$i.PossibleChannels': '1,6,11', + 'Device.WiFi.Radio.$i.OperatingStandards': 'n', + 'Device.WiFi.Radio.$i.SupportedStandards': 'b,g,n', + 'Device.WiFi.Radio.$i.TransmitPower': 100, + 'Device.WiFi.Radio.$i.MaxBitRate': 300, + 'Device.WiFi.Radio.$i.AutoChannelEnable': true, + 'Device.WiFi.Radio.$i.IEEE80211hEnabled': false, + 'Device.WiFi.Radio.$i.SupportedOperatingChannelBandwidths': + 'Auto,20MHz,40MHz', + }; + final wifiRadiosResponse = { + ...radioFields(1, '2.4GHz', 6), + ...radioFields(2, '5GHz', 36), + }; + + void stubWifiFetches() { + when(() => mockUsp.get(any(), priority: any(named: 'priority'))) + .thenAnswer((invocation) async { + final paths = invocation.positionalArguments[0] as List; + final first = paths.isNotEmpty ? paths.first : ''; + if (first.startsWith('Device.WiFi.SSID.')) { + return wifiSsidsResponse; + } + if (first.startsWith('Device.WiFi.AccessPoint.')) { + return wifiApsResponse; + } + if (first.startsWith('Device.WiFi.Radio.')) { + return wifiRadiosResponse; + } + return {}; + }); + } + + test('classifies -guest alias SSIDs as guest, others as main', () async { + stubWifiFetches(); + + final result = await service.fetchWizardData(); + final config = result.wifiConfig; + + // Main network built from the non-guest SSIDs. + expect(config.ssid, 'MyHome'); + // Guest network built from the -guest alias SSIDs. + expect(config.guestSsid, 'MyHome-Guest'); + expect(config.guestEnabled, isFalse); + expect( + config.guestAccessPointInstancePaths, + containsAll( + ['Device.WiFi.AccessPoint.3.', 'Device.WiFi.AccessPoint.4.'])); + // Guest AP paths must not leak into the main path list. + expect(config.accessPointInstancePaths, + isNot(contains('Device.WiFi.AccessPoint.3.'))); + }); + }); } diff --git a/test/page/internet_settings/models/internet_settings_read_only_info_test.dart b/test/page/internet_settings/models/internet_settings_read_only_info_test.dart new file mode 100644 index 000000000..024004bc7 --- /dev/null +++ b/test/page/internet_settings/models/internet_settings_read_only_info_test.dart @@ -0,0 +1,24 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:privacy_gui/page/internet_settings/models/internet_settings_read_only_info.dart'; + +void main() { + group('InternetSettingsReadOnlyInfo', () { + test('hostName defaults to empty string', () { + const info = InternetSettingsReadOnlyInfo(); + expect(info.hostName, ''); + }); + + test('hostName is stored and surfaced', () { + const info = InternetSettingsReadOnlyInfo(hostName: 'Community00080'); + expect(info.hostName, 'Community00080'); + }); + + test('hostName participates in equality', () { + const a = InternetSettingsReadOnlyInfo(hostName: 'A'); + const b = InternetSettingsReadOnlyInfo(hostName: 'B'); + const c = InternetSettingsReadOnlyInfo(hostName: 'A'); + expect(a, isNot(equals(b))); + expect(a, equals(c)); + }); + }); +} diff --git a/test/page/internet_settings/models/usp_internet_settings_form_test.dart b/test/page/internet_settings/models/usp_internet_settings_form_test.dart new file mode 100644 index 000000000..0a2715e70 --- /dev/null +++ b/test/page/internet_settings/models/usp_internet_settings_form_test.dart @@ -0,0 +1,166 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:privacy_gui/page/internet_settings/models/usp_internet_settings_form.dart'; +import 'package:privacy_gui/page/internet_settings/models/usp_wan_connection_type.dart'; + +void main() { + group('UspInternetSettingsForm', () { + group('defaults', () { + const form = UspInternetSettingsForm( + connectionType: UspWanConnectionType.dhcp, + ); + + test('connectionType is required and stored', () { + expect(form.connectionType, UspWanConnectionType.dhcp); + }); + + test('string fields default to empty', () { + expect(form.staticIpAddress, ''); + expect(form.subnetMask, ''); + expect(form.defaultGateway, ''); + expect(form.dnsServer1, ''); + expect(form.dnsServer2, ''); + expect(form.dnsServer3, ''); + expect(form.pppUsername, ''); + expect(form.pppPassword, ''); + expect(form.pppoeServiceName, ''); + expect(form.serverAddress, ''); + expect(form.wanMacAddress, ''); + expect(form.ipv6rdPrefix, ''); + expect(form.ipv6rdBorderRelay, ''); + }); + + test('connectionTrigger defaults to AlwaysOn', () { + expect(form.connectionTrigger, 'AlwaysOn'); + }); + + test('numeric fields default to zero', () { + expect(form.idleDisconnectTime, 0); + expect(form.lcpEchoInterval, 0); + expect(form.vlanId, 0); + expect(form.mtu, 0); + expect(form.ipv6rdIpv4MaskLength, 0); + }); + + test('boolean fields default to false', () { + expect(form.vlanEnabled, isFalse); + expect(form.ipv6Enabled, isFalse); + expect(form.dhcpv6Enabled, isFalse); + expect(form.ipv6rdEnabled, isFalse); + }); + }); + + group('copyWith', () { + const base = UspInternetSettingsForm( + connectionType: UspWanConnectionType.pptp, + serverAddress: 'vpn.example.com', + pppUsername: 'user', + mtu: 1460, + ); + + test('returns an equal instance when no overrides are given', () { + expect(base.copyWith(), equals(base)); + }); + + test('overrides only the specified field', () { + final updated = base.copyWith(serverAddress: 'new.example.com'); + expect(updated.serverAddress, 'new.example.com'); + // Untouched fields are preserved. + expect(updated.connectionType, base.connectionType); + expect(updated.pppUsername, base.pppUsername); + expect(updated.mtu, base.mtu); + }); + + test('updates each field independently', () { + expect( + base + .copyWith(connectionType: UspWanConnectionType.l2tp) + .connectionType, + UspWanConnectionType.l2tp, + ); + expect(base.copyWith(staticIpAddress: '10.0.0.1').staticIpAddress, + '10.0.0.1'); + expect(base.copyWith(subnetMask: '255.255.255.0').subnetMask, + '255.255.255.0'); + expect(base.copyWith(defaultGateway: '10.0.0.254').defaultGateway, + '10.0.0.254'); + expect(base.copyWith(dnsServer1: '8.8.8.8').dnsServer1, '8.8.8.8'); + expect(base.copyWith(dnsServer2: '8.8.4.4').dnsServer2, '8.8.4.4'); + expect(base.copyWith(dnsServer3: '1.1.1.1').dnsServer3, '1.1.1.1'); + expect(base.copyWith(pppPassword: 'secret').pppPassword, 'secret'); + expect(base.copyWith(pppoeServiceName: 'svc').pppoeServiceName, 'svc'); + expect(base.copyWith(connectionTrigger: 'OnDemand').connectionTrigger, + 'OnDemand'); + expect(base.copyWith(idleDisconnectTime: 30).idleDisconnectTime, 30); + expect(base.copyWith(lcpEchoInterval: 10).lcpEchoInterval, 10); + expect(base.copyWith(vlanEnabled: true).vlanEnabled, isTrue); + expect(base.copyWith(vlanId: 100).vlanId, 100); + expect(base.copyWith(mtu: 1492).mtu, 1492); + expect(base.copyWith(wanMacAddress: 'AA:BB:CC:DD:EE:FF').wanMacAddress, + 'AA:BB:CC:DD:EE:FF'); + expect(base.copyWith(ipv6Enabled: true).ipv6Enabled, isTrue); + expect(base.copyWith(dhcpv6Enabled: true).dhcpv6Enabled, isTrue); + expect(base.copyWith(ipv6rdEnabled: true).ipv6rdEnabled, isTrue); + expect(base.copyWith(ipv6rdPrefix: '2001:db8::/32').ipv6rdPrefix, + '2001:db8::/32'); + expect( + base.copyWith(ipv6rdIpv4MaskLength: 16).ipv6rdIpv4MaskLength, 16); + expect(base.copyWith(ipv6rdBorderRelay: '192.0.2.1').ipv6rdBorderRelay, + '192.0.2.1'); + }); + }); + + group('equality (dirty-check contract)', () { + const base = UspInternetSettingsForm( + connectionType: UspWanConnectionType.dhcp, + ); + + test('two instances with identical fields are equal', () { + const other = UspInternetSettingsForm( + connectionType: UspWanConnectionType.dhcp, + ); + expect(base, equals(other)); + expect(base.hashCode, other.hashCode); + }); + + // Guards the dirty-check contract: every field must participate in + // equality via `props`. If a field is added to the model but omitted + // from `props`, changing it would not mark the form dirty — this loop + // fails when that happens. + test('every field participates in equality', () { + final mutations = [ + base.copyWith(connectionType: UspWanConnectionType.pppoe), + base.copyWith(staticIpAddress: 'x'), + base.copyWith(subnetMask: 'x'), + base.copyWith(defaultGateway: 'x'), + base.copyWith(dnsServer1: 'x'), + base.copyWith(dnsServer2: 'x'), + base.copyWith(dnsServer3: 'x'), + base.copyWith(pppUsername: 'x'), + base.copyWith(pppPassword: 'x'), + base.copyWith(pppoeServiceName: 'x'), + base.copyWith(connectionTrigger: 'OnDemand'), + base.copyWith(idleDisconnectTime: 1), + base.copyWith(lcpEchoInterval: 1), + base.copyWith(serverAddress: 'x'), + base.copyWith(vlanEnabled: true), + base.copyWith(vlanId: 1), + base.copyWith(mtu: 1), + base.copyWith(wanMacAddress: 'x'), + base.copyWith(ipv6Enabled: true), + base.copyWith(dhcpv6Enabled: true), + base.copyWith(ipv6rdEnabled: true), + base.copyWith(ipv6rdPrefix: 'x'), + base.copyWith(ipv6rdIpv4MaskLength: 1), + base.copyWith(ipv6rdBorderRelay: 'x'), + ]; + + // Sanity: one mutation per field declared on the model. + expect(mutations.length, 24); + + for (final mutated in mutations) { + expect(mutated, isNot(equals(base))); + } + }); + }); + }); +} diff --git a/test/page/internet_settings/models/usp_wan_connection_type_test.dart b/test/page/internet_settings/models/usp_wan_connection_type_test.dart index 89acf8f8c..b91f75217 100644 --- a/test/page/internet_settings/models/usp_wan_connection_type_test.dart +++ b/test/page/internet_settings/models/usp_wan_connection_type_test.dart @@ -6,82 +6,68 @@ void main() { group('fromRawFields', () { test('returns dhcp for addressingType DHCP', () { expect( - UspWanConnectionType.fromRawFields( - addressingType: 'DHCP', - bridgeEnabled: false, - ), + UspWanConnectionType.fromRawFields(addressingType: 'DHCP'), UspWanConnectionType.dhcp, ); }); - test('returns dhcp for addressingType DHCP even when bridgeEnabled', () { + test('returns staticIp for addressingType Static', () { expect( - UspWanConnectionType.fromRawFields( - addressingType: 'DHCP', - bridgeEnabled: true, - ), - UspWanConnectionType.dhcp, + UspWanConnectionType.fromRawFields(addressingType: 'Static'), + UspWanConnectionType.staticIp, ); }); - test('returns staticIp for addressingType Static', () { + test('returns pppoe for addressingType IPCP with no lowerLayers', () { expect( - UspWanConnectionType.fromRawFields( - addressingType: 'Static', - bridgeEnabled: false, - ), - UspWanConnectionType.staticIp, + UspWanConnectionType.fromRawFields(addressingType: 'IPCP'), + UspWanConnectionType.pppoe, ); }); - test('returns pppoe for addressingType IPCP', () { + test('returns pppoe for IPCP with Ethernet lowerLayers', () { expect( UspWanConnectionType.fromRawFields( addressingType: 'IPCP', - bridgeEnabled: false, + lowerLayers: 'Device.Ethernet.Link.2', ), UspWanConnectionType.pppoe, ); }); - test('returns bridge when addressingType is empty and bridgeEnabled', () { + test('returns pptp for IPCP with GRE.Tunnel lowerLayers', () { expect( UspWanConnectionType.fromRawFields( - addressingType: '', - bridgeEnabled: true, + addressingType: 'IPCP', + lowerLayers: 'Device.GRE.Tunnel.1.Interface.1', ), - UspWanConnectionType.bridge, + UspWanConnectionType.pptp, ); }); - test('returns dhcp when addressingType is empty and bridgeEnabled false', - () { + test('returns l2tp for IPCP with L2TPv2.Tunnel lowerLayers', () { expect( UspWanConnectionType.fromRawFields( - addressingType: '', - bridgeEnabled: false, + addressingType: 'IPCP', + lowerLayers: 'Device.L2TPv2.Tunnel.1.Interface.1', ), - UspWanConnectionType.dhcp, + UspWanConnectionType.l2tp, ); }); - test('returns dhcp for unknown addressingType without bridgeEnabled', () { + test('returns bridge for empty addressingType', () { expect( - UspWanConnectionType.fromRawFields( - addressingType: 'Unknown', - bridgeEnabled: false, - ), - UspWanConnectionType.dhcp, + UspWanConnectionType.fromRawFields(addressingType: ''), + UspWanConnectionType.bridge, ); }); - test('returns dhcp for unknown addressingType even with bridgeEnabled', - () { + test('returns dhcp for unknown non-empty addressingType', () { + // Only an explicitly empty AddressingType signals bridge; any other + // unrecognised value (future firmware, transient) falls back to DHCP + // rather than being misclassified as bridge. expect( - UspWanConnectionType.fromRawFields( - addressingType: 'Unknown', - bridgeEnabled: true, - ), + UspWanConnectionType.fromRawFields(addressingType: 'Something'), UspWanConnectionType.dhcp, ); }); @@ -101,6 +87,14 @@ void main() { expect(UspWanConnectionType.pppoe.label, 'PPPoE'); }); + test('pptp label', () { + expect(UspWanConnectionType.pptp.label, 'PPTP'); + }); + + test('l2tp label', () { + expect(UspWanConnectionType.l2tp.label, 'L2TP'); + }); + test('bridge label', () { expect(UspWanConnectionType.bridge.label, 'Bridge Mode'); }); @@ -119,9 +113,122 @@ void main() { expect(UspWanConnectionType.pppoe.addressingTypeValue, 'IPCP'); }); + test('pptp returns IPCP', () { + expect(UspWanConnectionType.pptp.addressingTypeValue, 'IPCP'); + }); + + test('l2tp returns IPCP', () { + expect(UspWanConnectionType.l2tp.addressingTypeValue, 'IPCP'); + }); + test('bridge returns empty string', () { expect(UspWanConnectionType.bridge.addressingTypeValue, ''); }); }); + + group('isPppBased', () { + test('pppoe is PPP-based', () { + expect(UspWanConnectionType.pppoe.isPppBased, isTrue); + }); + + test('pptp is PPP-based', () { + expect(UspWanConnectionType.pptp.isPppBased, isTrue); + }); + + test('l2tp is PPP-based', () { + expect(UspWanConnectionType.l2tp.isPppBased, isTrue); + }); + + test('dhcp is not PPP-based', () { + expect(UspWanConnectionType.dhcp.isPppBased, isFalse); + }); + + test('staticIp is not PPP-based', () { + expect(UspWanConnectionType.staticIp.isPppBased, isFalse); + }); + + test('bridge is not PPP-based', () { + expect(UspWanConnectionType.bridge.isPppBased, isFalse); + }); + }); + + group('pppLowerLayers', () { + test('pppoe returns Ethernet.Link.2', () { + expect(UspWanConnectionType.pppoe.pppLowerLayers, + 'Device.Ethernet.Link.2'); + }); + + test('pptp returns GRE.Tunnel.1.Interface.1', () { + expect(UspWanConnectionType.pptp.pppLowerLayers, + 'Device.GRE.Tunnel.1.Interface.1'); + }); + + test('l2tp returns L2TPv2.Tunnel.1.Interface.1', () { + expect(UspWanConnectionType.l2tp.pppLowerLayers, + 'Device.L2TPv2.Tunnel.1.Interface.1'); + }); + + test('dhcp returns null', () { + expect(UspWanConnectionType.dhcp.pppLowerLayers, isNull); + }); + }); + + group('mtuMax', () { + test('pppoe reserves 8 bytes (1492)', () { + expect(UspWanConnectionType.pppoe.mtuMax, 1492); + }); + + test('pptp reserves tunnel overhead (1460)', () { + expect(UspWanConnectionType.pptp.mtuMax, 1460); + }); + + test('l2tp reserves tunnel overhead (1460)', () { + expect(UspWanConnectionType.l2tp.mtuMax, 1460); + }); + + test('dhcp uses Ethernet standard (1500)', () { + expect(UspWanConnectionType.dhcp.mtuMax, 1500); + }); + + test('staticIp uses Ethernet standard (1500)', () { + expect(UspWanConnectionType.staticIp.mtuMax, 1500); + }); + + test('bridge uses Ethernet standard (1500)', () { + expect(UspWanConnectionType.bridge.mtuMax, 1500); + }); + }); + + group('mtuMin', () { + test('is 576 for every type', () { + for (final type in UspWanConnectionType.values) { + expect(type.mtuMin, 576, reason: '${type.name} mtuMin'); + } + }); + }); + + group('clampMtu', () { + test('keeps an in-range value unchanged', () { + expect(UspWanConnectionType.pppoe.clampMtu(789), 789); + expect(UspWanConnectionType.dhcp.clampMtu(1500), 1500); + expect(UspWanConnectionType.pptp.clampMtu(1460), 1460); + }); + + test('resets an over-max value to the type max', () { + expect(UspWanConnectionType.pppoe.clampMtu(1500), 1492); + expect(UspWanConnectionType.pptp.clampMtu(1500), 1460); + }); + + test('resets a below-min value to the type max', () { + // 0 (auto, e.g. after leaving bridge) and any sub-576 value fall back + // to the max rather than an invalid low value. + expect(UspWanConnectionType.dhcp.clampMtu(0), 1500); + expect(UspWanConnectionType.pppoe.clampMtu(100), 1492); + }); + + test('keeps the min boundary value', () { + expect(UspWanConnectionType.dhcp.clampMtu(576), 576); + }); + }); }); } diff --git a/test/page/internet_settings/providers/usp_internet_settings_form_validator_test.dart b/test/page/internet_settings/providers/usp_internet_settings_form_validator_test.dart index c22384947..0d69a9aef 100644 --- a/test/page/internet_settings/providers/usp_internet_settings_form_validator_test.dart +++ b/test/page/internet_settings/providers/usp_internet_settings_form_validator_test.dart @@ -136,6 +136,148 @@ void main() { }); }); + group('PPTP connection type', () { + test('valid with server, username, and password', () { + final form = UspInternetSettingsForm( + connectionType: UspWanConnectionType.pptp, + serverAddress: 'vpn.example.com', + pppUsername: 'user', + pppPassword: 'pass', + mtu: 1460, + ); + expect(validateForm(form), isTrue); + }); + + test('valid with IP address as server', () { + final form = UspInternetSettingsForm( + connectionType: UspWanConnectionType.pptp, + serverAddress: '10.0.0.1', + pppUsername: 'user', + pppPassword: 'pass', + mtu: 1460, + ); + expect(validateForm(form), isTrue); + }); + + test('invalid when server address is empty', () { + final form = UspInternetSettingsForm( + connectionType: UspWanConnectionType.pptp, + serverAddress: '', + pppUsername: 'user', + pppPassword: 'pass', + mtu: 1460, + ); + expect(validateForm(form), isFalse); + }); + + test('invalid when username is empty', () { + final form = UspInternetSettingsForm( + connectionType: UspWanConnectionType.pptp, + serverAddress: 'vpn.example.com', + pppUsername: '', + pppPassword: 'pass', + mtu: 1460, + ); + expect(validateForm(form), isFalse); + }); + + test('invalid when password is empty', () { + final form = UspInternetSettingsForm( + connectionType: UspWanConnectionType.pptp, + serverAddress: 'vpn.example.com', + pppUsername: 'user', + pppPassword: '', + mtu: 1460, + ); + expect(validateForm(form), isFalse); + }); + + test('invalid when OnDemand with zero idle time', () { + final form = UspInternetSettingsForm( + connectionType: UspWanConnectionType.pptp, + serverAddress: 'vpn.example.com', + pppUsername: 'user', + pppPassword: 'pass', + connectionTrigger: 'OnDemand', + idleDisconnectTime: 0, + mtu: 1460, + ); + expect(validateForm(form), isFalse); + }); + + test('valid when OnDemand with positive idle time', () { + final form = UspInternetSettingsForm( + connectionType: UspWanConnectionType.pptp, + serverAddress: 'vpn.example.com', + pppUsername: 'user', + pppPassword: 'pass', + connectionTrigger: 'OnDemand', + idleDisconnectTime: 300, + mtu: 1460, + ); + expect(validateForm(form), isTrue); + }); + }); + + group('L2TP connection type', () { + test('valid with server, username, and password', () { + final form = UspInternetSettingsForm( + connectionType: UspWanConnectionType.l2tp, + serverAddress: 'l2tp.example.com', + pppUsername: 'user', + pppPassword: 'pass', + mtu: 1460, + ); + expect(validateForm(form), isTrue); + }); + + test('invalid when server address is empty', () { + final form = UspInternetSettingsForm( + connectionType: UspWanConnectionType.l2tp, + serverAddress: '', + pppUsername: 'user', + pppPassword: 'pass', + mtu: 1460, + ); + expect(validateForm(form), isFalse); + }); + + test('invalid with invalid server format', () { + final form = UspInternetSettingsForm( + connectionType: UspWanConnectionType.l2tp, + serverAddress: 'has spaces bad', + pppUsername: 'user', + pppPassword: 'pass', + mtu: 1460, + ); + expect(validateForm(form), isFalse); + }); + }); + + group('PPTP/L2TP MTU validation', () { + test('valid at maximum 1460', () { + final form = UspInternetSettingsForm( + connectionType: UspWanConnectionType.pptp, + serverAddress: 'vpn.example.com', + pppUsername: 'user', + pppPassword: 'pass', + mtu: 1460, + ); + expect(validateForm(form), isTrue); + }); + + test('invalid above 1460', () { + final form = UspInternetSettingsForm( + connectionType: UspWanConnectionType.pptp, + serverAddress: 'vpn.example.com', + pppUsername: 'user', + pppPassword: 'pass', + mtu: 1492, + ); + expect(validateForm(form), isFalse); + }); + }); + group('Bridge connection type', () { test('always valid', () { final form = UspInternetSettingsForm( diff --git a/test/page/internet_settings/providers/usp_internet_settings_notifier_test.dart b/test/page/internet_settings/providers/usp_internet_settings_notifier_test.dart index 03f38c4b7..6fbda46df 100644 --- a/test/page/internet_settings/providers/usp_internet_settings_notifier_test.dart +++ b/test/page/internet_settings/providers/usp_internet_settings_notifier_test.dart @@ -5,6 +5,8 @@ import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/core/usp/providers/usp_auth_coordinator.dart'; import 'package:privacy_gui/core/usp/providers/usp_mutation_lock.dart'; import 'package:privacy_gui/core/usp/providers/usp_client_provider.dart'; +import 'package:privacy_gui/core/usp/providers/sse_providers.dart'; +import 'package:privacy_gui/core/usp/services/sse_manager.dart'; import 'package:privacy_gui/core/usp/services/usp_client.dart'; import 'package:privacy_gui/page/internet_settings/models/internet_settings_read_only_info.dart'; import 'package:privacy_gui/page/internet_settings/models/usp_internet_settings_form.dart'; @@ -19,10 +21,13 @@ class MockUspInternetSettingsService extends Mock class MockUspAuthCoordinator extends Mock implements UspAuthCoordinator {} +class MockSseManager extends Mock implements SseManager {} + void main() { late MockUspClient mockUsp; late MockUspInternetSettingsService mockService; late MockUspAuthCoordinator mockAuthCoordinator; + late MockSseManager mockSseManager; final testForm = UspInternetSettingsForm( connectionType: UspWanConnectionType.dhcp, @@ -43,7 +48,9 @@ void main() { mockUsp = MockUspClient(); mockService = MockUspInternetSettingsService(); mockAuthCoordinator = MockUspAuthCoordinator(); + mockSseManager = MockSseManager(); when(() => mockUsp.isAuthenticated).thenReturn(true); + when(() => mockSseManager.disconnect()).thenAnswer((_) async {}); }); ProviderContainer createContainer() { @@ -53,6 +60,7 @@ void main() { uspInternetSettingsServiceProvider.overrideWithValue(mockService), uspMutationLockProvider.overrideWithValue(UspMutationLock()), uspAuthCoordinatorProvider.overrideWithValue(mockAuthCoordinator), + sseManagerProvider.overrideWithValue(mockSseManager), ], ); container.listen(uspInternetSettingsProvider, (_, __) {}); @@ -173,6 +181,67 @@ void main() { container.dispose(); }); + test('updateConnectionType to PPPoE clamps over-limit MTU to 1492', + () async { + when(() => mockService.fetchSettings()) + .thenAnswer((_) async => testFetchResult); + final container = createContainer(); + await Future.delayed(Duration.zero); + + // Initial MTU is 1500 (DHCP), which exceeds PPPoE's 1492 max. + container + .read(uspInternetSettingsProvider.notifier) + .updateConnectionType(UspWanConnectionType.pppoe); + + final form = + container.read(uspInternetSettingsProvider).settings.current.form; + expect(form.connectionType, UspWanConnectionType.pppoe); + expect(form.mtu, 1492); + container.dispose(); + }); + + test('updateConnectionType from bridge auto-fills MTU with type max', + () async { + when(() => mockService.fetchSettings()) + .thenAnswer((_) async => testFetchResult); + final container = createContainer(); + await Future.delayed(Duration.zero); + + final notifier = container.read(uspInternetSettingsProvider.notifier); + // Bridge sets mtu = 0 (auto)... + notifier.updateConnectionType(UspWanConnectionType.bridge); + expect( + container.read(uspInternetSettingsProvider).settings.current.form.mtu, + 0); + + // ...switching back to DHCP must not leave MTU empty; 0 is out of range + // so it falls back to the type max (1500). + notifier.updateConnectionType(UspWanConnectionType.dhcp); + final form = + container.read(uspInternetSettingsProvider).settings.current.form; + expect(form.connectionType, UspWanConnectionType.dhcp); + expect(form.mtu, 1500); + container.dispose(); + }); + + test('updateConnectionType keeps an in-range MTU unchanged', () async { + when(() => mockService.fetchSettings()) + .thenAnswer((_) async => testFetchResult); + final container = createContainer(); + await Future.delayed(Duration.zero); + + final notifier = container.read(uspInternetSettingsProvider.notifier); + // 789 is valid for both DHCP and PPPoE (issue #1083 "last changed value"). + notifier.updateField((f) => f.copyWith(mtu: 789)); + notifier.updateConnectionType(UspWanConnectionType.pppoe); + + final form = + container.read(uspInternetSettingsProvider).settings.current.form; + expect(form.connectionType, UspWanConnectionType.pppoe); + expect(form.mtu, 789); + container.dispose(); + }); + test('isDirty after field update, clean after revert', () async { when(() => mockService.fetchSettings()) .thenAnswer((_) async => testFetchResult); @@ -226,6 +295,58 @@ void main() { container.dispose(); }); + test( + 'save entering bridge disconnects SSE and skips the post-save re-fetch', + () async { + when(() => mockService.fetchSettings()) + .thenAnswer((_) async => testFetchResult); + when(() => mockService.saveAll(any(), any())).thenAnswer((_) async {}); + + final container = createContainer(); + await Future.delayed(Duration.zero); + + final notifier = container.read(uspInternetSettingsProvider.notifier); + notifier.enterEditMode(); + // Baseline is DHCP; switching to bridge is an entering-bridge transition. + notifier.updateConnectionType(UspWanConnectionType.bridge); + await notifier.save(); + + // SSE is dropped so the recovery flow never fires on top of the dialog. + verify(() => mockSseManager.disconnect()).called(1); + // Only the initial build() fetch — save() must NOT re-fetch, because the + // router is now unreachable on this origin (a re-fetch would time out and + // surface a spurious error for a save that actually succeeded). + verify(() => mockService.fetchSettings()).called(1); + // Form still reflects the edited bridge value; state stays clean. + final state = container.read(uspInternetSettingsProvider); + expect(state.settings.current.form.connectionType, + UspWanConnectionType.bridge); + expect(state.status.isEditing, isFalse); + expect(notifier.isDirty(), isFalse); + container.dispose(); + }); + + test('save for a non-bridge change re-fetches and does not disconnect SSE', + () async { + when(() => mockService.fetchSettings()) + .thenAnswer((_) async => testFetchResult); + when(() => mockService.saveAll(any(), any())).thenAnswer((_) async {}); + + final container = createContainer(); + await Future.delayed(Duration.zero); + + final notifier = container.read(uspInternetSettingsProvider.notifier); + notifier.enterEditMode(); + // Stays DHCP — only an unrelated field changes. + notifier.updateField((f) => f.copyWith(mtu: 9000)); + await notifier.save(); + + verifyNever(() => mockSseManager.disconnect()); + // build() fetch + post-save re-fetch = 2 fetchSettings calls. + verify(() => mockService.fetchSettings()).called(2); + container.dispose(); + }); + test('performSave sets isSaving flag during save', () async { when(() => mockService.fetchSettings()) .thenAnswer((_) async => testFetchResult); @@ -300,9 +421,13 @@ void main() { container.dispose(); }); - test('fetch with unauthenticated service sets error status', () async { + // Regression: issue #1119. The provider must NOT gate its fetch on the raw + // usp.isAuthenticated flag — in Remote Assistance that flag stays false by + // design (authToken bypass), yet the data layer serves fine. Auth is + // enforced by the router, not here. + test('fetch proceeds when usp.isAuthenticated is false (RA bypass)', + () async { when(() => mockUsp.isAuthenticated).thenReturn(false); - when(() => mockAuthCoordinator.restoreSession()).thenAnswer((_) async {}); when(() => mockService.fetchSettings()) .thenAnswer((_) async => testFetchResult); @@ -310,8 +435,29 @@ void main() { await Future.delayed(Duration.zero); final state = container.read(uspInternetSettingsProvider); - // Should hit the restore path which won't succeed with our mock. - expect(state.status.error, isNotNull); + expect(state.status.error, isNull); + expect(state.status.isLoading, isFalse); + expect(state.settings.current.form.connectionType, + UspWanConnectionType.dhcp); + verify(() => mockService.fetchSettings()).called(1); + container.dispose(); + }); + + test('fetch throws ServiceNotInitializedError when usp is null', () async { + final container = ProviderContainer( + overrides: [ + uspClientProvider.overrideWithValue(null), + uspInternetSettingsServiceProvider.overrideWithValue(mockService), + uspMutationLockProvider.overrideWithValue(UspMutationLock()), + uspAuthCoordinatorProvider.overrideWithValue(mockAuthCoordinator), + sseManagerProvider.overrideWithValue(mockSseManager), + ], + ); + container.listen(uspInternetSettingsProvider, (_, __) {}); + await Future.delayed(Duration.zero); + + final state = container.read(uspInternetSettingsProvider); + expect(state.status.error, isA()); container.dispose(); }); }); diff --git a/test/page/internet_settings/services/usp_internet_settings_service_test.dart b/test/page/internet_settings/services/usp_internet_settings_service_test.dart index 43d718e47..6002c12b2 100644 --- a/test/page/internet_settings/services/usp_internet_settings_service_test.dart +++ b/test/page/internet_settings/services/usp_internet_settings_service_test.dart @@ -1,3 +1,6 @@ +import 'dart:async'; + +import 'package:fake_async/fake_async.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:privacy_gui/core/errors/service_error.dart'; @@ -31,6 +34,50 @@ const _pppResponse = { 'Device.PPP.Interface.1.IdleDisconnectTime': '0', 'Device.PPP.Interface.1.LCPEcho': '30', 'Device.PPP.Interface.1.ConnectionStatus': 'Connected', + 'Device.PPP.Interface.1.LowerLayers': '', +}; + +// Test data — PPP with GRE LowerLayers (PPTP) +const _pppPptpResponse = { + 'Device.PPP.Interface.1.Username': 'vpnuser', + 'Device.PPP.Interface.1.Password': 'vpnpass', + 'Device.PPP.Interface.1.PPPoE.ServiceName': '', + 'Device.PPP.Interface.1.ConnectionTrigger': 'AlwaysOn', + 'Device.PPP.Interface.1.IdleDisconnectTime': '0', + 'Device.PPP.Interface.1.LCPEcho': '0', + 'Device.PPP.Interface.1.ConnectionStatus': 'Connected', + 'Device.PPP.Interface.1.LowerLayers': 'Device.GRE.Tunnel.1.Interface.1', +}; + +// Test data — PPP with L2TPv2 LowerLayers (L2TP) +const _pppL2tpResponse = { + 'Device.PPP.Interface.1.Username': 'l2tpuser', + 'Device.PPP.Interface.1.Password': 'l2tppass', + 'Device.PPP.Interface.1.PPPoE.ServiceName': '', + 'Device.PPP.Interface.1.ConnectionTrigger': 'AlwaysOn', + 'Device.PPP.Interface.1.IdleDisconnectTime': '0', + 'Device.PPP.Interface.1.LCPEcho': '0', + 'Device.PPP.Interface.1.ConnectionStatus': 'Connected', + 'Device.PPP.Interface.1.LowerLayers': 'Device.L2TPv2.Tunnel.1.Interface.1', +}; + +// Test data — GRE Tunnel +const _greResponse = { + 'Device.GRE.Tunnel.1.RemoteEndpoints': 'pptp.example.com', +}; + +// Test data — L2TP Tunnel +const _l2tpResponse = { + 'Device.L2TPv2.Tunnel.1.RemoteEndpoints': 'l2tp.example.com', +}; + +// Test data — GRE/L2TP empty +const _greEmptyResponse = { + 'Device.GRE.Tunnel.1.RemoteEndpoints': '', +}; + +const _l2tpEmptyResponse = { + 'Device.L2TPv2.Tunnel.1.RemoteEndpoints': '', }; // Test data — PPP empty (no instances) @@ -68,8 +115,11 @@ const _ethLinkAliasResponse = { /// Helper to create a mock get handler that handles all codegen paths Map Function(List) createFetchMockHandler({ + Map wanResponse = _wanResponse, Map pppResponse = _pppResponse, Map vlanResponse = _vlanResponse, + Map greResponse = _greEmptyResponse, + Map l2tpResponse = _l2tpEmptyResponse, }) { return (List paths) { // Alias resolution (must be checked first) @@ -79,9 +129,16 @@ Map Function(List) createFetchMockHandler({ if (paths.any((p) => p.contains('IP.Interface.*.Alias'))) { return _ipAliasResponse; } + // Tunnel fetches + if (paths.any((p) => p.contains('GRE.Tunnel'))) { + return greResponse; + } + if (paths.any((p) => p.contains('L2TPv2.Tunnel'))) { + return l2tpResponse; + } // Other fetches if (paths.any((p) => p.contains('AddressingType'))) { - return _wanResponse; + return wanResponse; } if (paths.any((p) => p.contains('IPv6Enable'))) { return _ipv6Response; @@ -92,6 +149,9 @@ Map Function(List) createFetchMockHandler({ if (paths.any((p) => p.contains('VLANTermination'))) { return vlanResponse; } + if (paths.any((p) => p.contains('DeviceInfo.HostName'))) { + return const {'Device.DeviceInfo.HostName': 'Community00080'}; + } return {}; }; } @@ -131,6 +191,18 @@ void main() { expect(result.readOnlyInfo.pppConnectionStatus, equals('Connected')); }); + test('fetches and exposes router hostName', () async { + final handler = createFetchMockHandler(); + when(() => mockUsp.get(any())).thenAnswer((invocation) async { + final paths = invocation.positionalArguments[0] as List; + return handler(paths); + }); + + final result = await service.fetchSettings(); + + expect(result.readOnlyInfo.hostName, equals('Community00080')); + }); + test('handles empty PPP and VLAN instances gracefully', () async { final handler = createFetchMockHandler( pppResponse: _pppEmptyResponse, @@ -165,6 +237,12 @@ void main() { if (paths.any((p) => p.contains('IP.Interface.*.Alias'))) { return _ipAliasResponse; } + if (paths.any((p) => p.contains('GRE.Tunnel'))) { + return _greEmptyResponse; + } + if (paths.any((p) => p.contains('L2TPv2.Tunnel'))) { + return _l2tpEmptyResponse; + } if (paths.any((p) => p.contains('AddressingType'))) return wanWith3Dns; if (paths.any((p) => p.contains('IPv6Enable'))) return _ipv6Response; if (paths.any((p) => p.contains('PPP.Interface'))) { @@ -524,6 +602,154 @@ void main() { expect(bridgeParams.first.length, equals(1)); }); + test('switching to Bridge never sends MaxMTUSize (mtu=0 sentinel)', + () async { + // updateConnectionType resets the form's mtu to 0 when switching to + // bridge. MaxMTUSize=0 fails FW range validation (64..65535), so the MTU + // SET must be skipped entirely in bridge mode — otherwise saveAll aborts + // before the terminal bridge SET ever goes out. + final original = UspInternetSettingsForm( + connectionType: UspWanConnectionType.dhcp, + mtu: 1500, + ); + final edited = original.copyWith( + connectionType: UspWanConnectionType.bridge, + mtu: 0, + ); + + await service.saveAll(original, edited); + + final captured = verify( + () => + mockUsp.set(captureAny(), allowPartial: any(named: 'allowPartial')), + ).captured; + for (final params in captured.whereType>()) { + expect(params.containsKey('Device.IP.Interface.2.MaxMTUSize'), isFalse, + reason: 'bridge mode must not push MaxMTUSize'); + } + }); + + test( + 'entering bridge treats a transport error on the bridge SET as success', + () async { + // The firmware applies bridge mode and drops this connection ~2s after + // receiving the SET, so its response never arrives — the request fails + // with a transport error. That is the expected signature of success, so + // saveAll must complete normally (no throw), not surface a spurious error. + when(() => mockUsp.set(any(), allowPartial: any(named: 'allowPartial'))) + .thenThrow('Set failed: Transport error: Request timeout'); + + final original = UspInternetSettingsForm( + connectionType: UspWanConnectionType.dhcp, + ); + final edited = original.copyWith( + connectionType: UspWanConnectionType.bridge, + ); + + await expectLater(service.saveAll(original, edited), completes); + }); + + test('entering bridge swallows a Dart timeout on the bridge SET', () { + // When no transport error arrives first, the .timeout(4s) budget elapses + // and throws TimeoutException — also the disconnect signature. Drive the + // 4s with fake_async so the test does not actually wait. + fakeAsync((async) { + // Bridge SET never completes (connection gone); other SETs are no-ops. + when(() => mockUsp.set(any(), allowPartial: any(named: 'allowPartial'))) + .thenAnswer((_) => Completer>().future); + + final original = UspInternetSettingsForm( + connectionType: UspWanConnectionType.dhcp, + ); + final edited = original.copyWith( + connectionType: UspWanConnectionType.bridge, + ); + + Object? error; + var done = false; + service.saveAll(original, edited).then( + (_) => done = true, + onError: (Object e) => error = e, + ); + + async.elapse(const Duration(seconds: 5)); + + expect(error, isNull, + reason: 'a bridge-apply timeout must be treated as success'); + expect(done, isTrue); + }); + }); + + test('entering bridge rethrows a real fault on the bridge SET', () async { + // A fault code means the router actively rejected the SET BEFORE any + // disconnect — a genuine config failure that must still reach the user, + // never swallowed by the fire-and-forget handling. + when(() => mockUsp.set(any(), allowPartial: any(named: 'allowPartial'))) + .thenAnswer((_) async => { + 'success': false, + 'result': { + 'data': {}, + 'error': { + 'Device.IP.Interface.2.IPv4Address.1.AddressingType': { + 'errorCode': 7006, + 'errorMessage': 'Invalid value', + }, + }, + }, + }); + + final original = UspInternetSettingsForm( + connectionType: UspWanConnectionType.dhcp, + ); + final edited = original.copyWith( + connectionType: UspWanConnectionType.bridge, + ); + + await expectLater( + service.saveAll(original, edited), + throwsA(isA()), + ); + }); + + test('entering bridge sends the terminal bridge SET after the IPv6 SET', + () async { + // Part B: FW-spec SETs (here IPv6) must land on a live connection, so the + // terminal bridge SET is deferred to last even when both change at once. + final captureOrder = >[]; + when(() => mockUsp.set(any(), allowPartial: any(named: 'allowPartial'))) + .thenAnswer((invocation) async { + captureOrder + .add(invocation.positionalArguments[0] as Map); + return { + 'success': true, + 'result': {'data': {}} + }; + }); + + final original = UspInternetSettingsForm( + connectionType: UspWanConnectionType.dhcp, + ipv6Enabled: true, + ); + final edited = original.copyWith( + connectionType: UspWanConnectionType.bridge, + ipv6Enabled: false, + ); + + await service.saveAll(original, edited); + + final ipv6Index = captureOrder.indexWhere( + (m) => m.keys.any((k) => k.contains('IPv6') || k.contains('DHCPv6'))); + final bridgeIndex = captureOrder.indexWhere((m) => + m.containsKey('Device.IP.Interface.2.IPv4Address.1.AddressingType')); + + expect(ipv6Index, greaterThanOrEqualTo(0), + reason: 'IPv6 change should have produced a SET'); + expect(bridgeIndex, greaterThanOrEqualTo(0), + reason: 'bridge switch should have produced a SET'); + expect(bridgeIndex, greaterThan(ipv6Index), + reason: 'bridge SET must be sent after the IPv6 SET'); + }); + test('MTU change without type change sends only MTU param', () async { final original = UspInternetSettingsForm( connectionType: UspWanConnectionType.dhcp, @@ -548,6 +774,136 @@ void main() { ); expect(mtuParams.first.length, equals(1)); }); + + test('switching to PPTP sets LowerLayers, RemoteEndpoints, and IPCP', + () async { + final original = UspInternetSettingsForm( + connectionType: UspWanConnectionType.dhcp, + ); + final edited = original.copyWith( + connectionType: UspWanConnectionType.pptp, + serverAddress: 'pptp.example.com', + pppUsername: 'vpnuser', + pppPassword: 'vpnpass', + ); + + await service.saveAll( + original, + edited, + pppInstancePath: 'Device.PPP.Interface.1.', + ); + + final captured = verify( + () => + mockUsp.set(captureAny(), allowPartial: any(named: 'allowPartial')), + ).captured; + final allParams = captured.whereType>().toList(); + + // Verify LowerLayers was set to GRE + final lowerLayersSet = allParams + .where((m) => m.containsKey('Device.PPP.Interface.1.LowerLayers')); + expect(lowerLayersSet, isNotEmpty); + expect(lowerLayersSet.first['Device.PPP.Interface.1.LowerLayers'], + equals('Device.GRE.Tunnel.1.Interface.1')); + + // Verify RemoteEndpoints was set + final greSet = allParams + .where((m) => m.containsKey('Device.GRE.Tunnel.1.RemoteEndpoints')); + expect(greSet, isNotEmpty); + expect(greSet.first['Device.GRE.Tunnel.1.RemoteEndpoints'], + equals('pptp.example.com')); + + // Verify AddressingType set to IPCP + final ipcpSet = allParams.where((m) => + m.containsKey('Device.IP.Interface.2.IPv4Address.1.AddressingType')); + expect(ipcpSet, isNotEmpty); + expect( + ipcpSet.first['Device.IP.Interface.2.IPv4Address.1.AddressingType'], + equals('IPCP')); + }); + + test('switching to L2TP sets LowerLayers to L2TPv2', () async { + final original = UspInternetSettingsForm( + connectionType: UspWanConnectionType.dhcp, + ); + final edited = original.copyWith( + connectionType: UspWanConnectionType.l2tp, + serverAddress: 'l2tp.example.com', + pppUsername: 'l2tpuser', + pppPassword: 'l2tppass', + ); + + await service.saveAll( + original, + edited, + pppInstancePath: 'Device.PPP.Interface.1.', + ); + + final captured = verify( + () => + mockUsp.set(captureAny(), allowPartial: any(named: 'allowPartial')), + ).captured; + final allParams = captured.whereType>().toList(); + + // Verify LowerLayers was set to L2TPv2 + final lowerLayersSet = allParams + .where((m) => m.containsKey('Device.PPP.Interface.1.LowerLayers')); + expect(lowerLayersSet, isNotEmpty); + expect(lowerLayersSet.first['Device.PPP.Interface.1.LowerLayers'], + equals('Device.L2TPv2.Tunnel.1.Interface.1')); + + // Verify L2TP RemoteEndpoints was set + final l2tpSet = allParams.where( + (m) => m.containsKey('Device.L2TPv2.Tunnel.1.RemoteEndpoints')); + expect(l2tpSet, isNotEmpty); + expect(l2tpSet.first['Device.L2TPv2.Tunnel.1.RemoteEndpoints'], + equals('l2tp.example.com')); + }); + + test('fetch detects PPTP from lowerLayers containing GRE.Tunnel', () async { + final wanIpcp = Map.from(_wanResponse); + wanIpcp['Device.IP.Interface.2.IPv4Address.1.AddressingType'] = 'IPCP'; + + final handler = createFetchMockHandler( + wanResponse: wanIpcp, + pppResponse: _pppPptpResponse, + greResponse: _greResponse, + l2tpResponse: _l2tpEmptyResponse, + ); + when(() => mockUsp.get(any())).thenAnswer((invocation) async { + final paths = invocation.positionalArguments[0] as List; + return handler(paths); + }); + + final result = await service.fetchSettings(); + + expect(result.form.connectionType, equals(UspWanConnectionType.pptp)); + expect(result.form.serverAddress, equals('pptp.example.com')); + expect(result.form.pppUsername, equals('vpnuser')); + }); + + test('fetch detects L2TP from lowerLayers containing L2TPv2.Tunnel', + () async { + final wanIpcp = Map.from(_wanResponse); + wanIpcp['Device.IP.Interface.2.IPv4Address.1.AddressingType'] = 'IPCP'; + + final handler = createFetchMockHandler( + wanResponse: wanIpcp, + pppResponse: _pppL2tpResponse, + greResponse: _greEmptyResponse, + l2tpResponse: _l2tpResponse, + ); + when(() => mockUsp.get(any())).thenAnswer((invocation) async { + final paths = invocation.positionalArguments[0] as List; + return handler(paths); + }); + + final result = await service.fetchSettings(); + + expect(result.form.connectionType, equals(UspWanConnectionType.l2tp)); + expect(result.form.serverAddress, equals('l2tp.example.com')); + expect(result.form.pppUsername, equals('l2tpuser')); + }); }); group('renewDhcpLease', () { @@ -705,28 +1061,6 @@ void main() { ); }); - test('renewDhcpLease throws UspCompleteFailureError on OPERATE failure', - () async { - // WASM v0.11.0 format: success=false for OPERATE - when(() => mockUsp.operate(any())).thenAnswer((_) async => { - 'success': false, - 'result': { - 'data': {}, - 'error': { - 'Device.DHCPv4.Client.1.Renew()': { - 'errorCode': 7012, - 'errorMessage': 'Command failure', - }, - }, - }, - }); - - expect( - () => service.renewDhcpLease(), - throwsA(isA()), - ); - }); - test('UspCompleteFailureError contains correct error message', () async { // Mock for _resolveInstance() when(() => mockUsp.get(any())).thenAnswer((_) async => aliasResponse); diff --git a/test/page/internet_settings/services/usp_wan_data_service_test.dart b/test/page/internet_settings/services/usp_wan_data_service_test.dart index fb2a9baec..4962db147 100644 --- a/test/page/internet_settings/services/usp_wan_data_service_test.dart +++ b/test/page/internet_settings/services/usp_wan_data_service_test.dart @@ -137,6 +137,46 @@ void main() { expect(result.ipv6Addresses, contains('2001:db8::1')); expect(result.ipv6Addresses, contains('2001:db8::2')); }); + + test('global IPv6 surfaces first, link-local kept at end (issue #1128)', + () async { + // Instance order as reported by the router in the #1128 diagnostic log: + // instance 1 is the link-local fe80:: address. + stubWanStatus( + ipv6Enabled: true, + ipv6Addresses: const [ + 'fe80::7612:13ff:fe21:5394', + '2401:e180:8831:505f::1', + '2401:e180:8831:505f:7612:13ff:fe21:5394', + '2401:e180:8801:d79d:7612:13ff:fe21:5394', + ], + ); + + final result = await svc.fetch(); + + // The widget shows ipv6Addresses.first, which must be a global unicast + // address. Link-local is not filtered — every address is kept and merely + // reordered so global unicast wins; the UI tags the link-local one with a + // scope badge. So all 4 remain and the link-local sinks to the end. + expect(result.ipv6Addresses, hasLength(4)); + expect(result.ipv6Addresses.first, '2401:e180:8831:505f::1'); + expect(result.ipv6Addresses.last, 'fe80::7612:13ff:fe21:5394'); + }); + + test('link-local-only WAN keeps the link-local address (issue #1128)', + () async { + // Real case observed on an M60TB whose upstream assigns no IPv6 prefix: + // the WAN interface (eth0) holds only a scope-link fe80:: address. It is + // still surfaced (tagged with a scope badge by the UI), not hidden. + stubWanStatus( + ipv6Enabled: true, + ipv6Addresses: const ['fe80::7612:13ff:fe21:5502'], + ); + + final result = await svc.fetch(); + + expect(result.ipv6Addresses, ['fe80::7612:13ff:fe21:5502']); + }); }); // --------------------------------------------------------------------------- diff --git a/test/page/internet_settings/views/helpers/bridge_redirect_dialog_test.dart b/test/page/internet_settings/views/helpers/bridge_redirect_dialog_test.dart new file mode 100644 index 000000000..c0d01b1d6 --- /dev/null +++ b/test/page/internet_settings/views/helpers/bridge_redirect_dialog_test.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:privacy_gui/l10n/gen/app_localizations.dart'; +import 'package:privacy_gui/page/internet_settings/views/helpers/bridge_redirect_dialog.dart'; +import 'package:ui_kit_library/ui_kit.dart'; + +final _testTheme = AppTheme.create( + brightness: Brightness.light, + seedColor: Colors.blue, + designThemeBuilder: (c) => CustomDesignTheme.fromJson({ + 'style': 'flat', + }), +); + +Widget _harness({required void Function(String) navigate}) { + return MaterialApp( + theme: _testTheme, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () => showBridgeRedirectDialog( + context, + hostName: 'Community00080', + navigate: navigate, + ), + child: const Text('open'), + ), + ), + ), + ); +} + +void main() { + testWidgets('shows the .local management address', (tester) async { + await tester.pumpWidget(_harness(navigate: (_) {})); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + expect( + find.textContaining('https://Community00080.local'), + findsWidgets, + ); + }); + + testWidgets('go button navigates to the .local URL', (tester) async { + String? navigated; + await tester.pumpWidget(_harness(navigate: (url) => navigated = url)); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + // The primary button carries the short "Go to router" label; the URL flows + // through onTap rather than the label. + await tester.tap(find.text('Go to router')); + await tester.pumpAndSettle(); + + expect(navigated, 'https://Community00080.local'); + }); + + testWidgets('offers no dismiss/close action — redirect is the only path', + (tester) async { + await tester.pumpWidget(_harness(navigate: (_) {})); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + // Once bridge mode is applied the router is unreachable on this origin, so + // the dialog must not offer a way to stay on the (now-dead) page. + expect(find.text('Go to router'), findsOneWidget); + expect(find.text('Close'), findsNothing); + }); +} diff --git a/test/page/internet_settings/views/sections/usp_ipv4_section_bridge_hint_test.dart b/test/page/internet_settings/views/sections/usp_ipv4_section_bridge_hint_test.dart new file mode 100644 index 000000000..d9f4c452f --- /dev/null +++ b/test/page/internet_settings/views/sections/usp_ipv4_section_bridge_hint_test.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:privacy_gui/l10n/gen/app_localizations.dart'; +import 'package:privacy_gui/page/internet_settings/models/internet_settings_feature_state.dart'; +import 'package:privacy_gui/page/internet_settings/models/internet_settings_read_only_info.dart'; +import 'package:privacy_gui/page/internet_settings/models/internet_settings_settings.dart'; +import 'package:privacy_gui/page/internet_settings/models/internet_settings_status.dart'; +import 'package:privacy_gui/page/internet_settings/models/usp_internet_settings_form.dart'; +import 'package:privacy_gui/page/internet_settings/models/usp_wan_connection_type.dart'; +import 'package:privacy_gui/page/internet_settings/views/sections/usp_ipv4_section.dart'; +import 'package:privacy_gui/framework/preservable.dart'; +import 'package:ui_kit_library/ui_kit.dart'; + +final _testTheme = AppTheme.create( + brightness: Brightness.light, + seedColor: Colors.blue, + designThemeBuilder: (c) => CustomDesignTheme.fromJson({ + 'style': 'flat', + }), +); + +InternetSettingsFeatureState _bridgeState({ + required bool editing, + required String hostName, +}) { + const form = UspInternetSettingsForm( + connectionType: UspWanConnectionType.bridge, + ); + return InternetSettingsFeatureState( + settings: Preservable( + original: const InternetSettingsSettings(form: form), + current: const InternetSettingsSettings(form: form), + ), + status: InternetSettingsStatus( + isLoading: false, + isEditing: editing, + readOnlyInfo: InternetSettingsReadOnlyInfo(hostName: hostName), + ), + ); +} + +Widget _host(InternetSettingsFeatureState state, bool editing) { + return ProviderScope( + child: MaterialApp( + theme: _testTheme, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SingleChildScrollView( + child: UspIpv4Section(state: state, isEditing: editing), + ), + ), + ), + ); +} + +void main() { + testWidgets('shows bold .local hint when editing bridge with a hostname', + (tester) async { + final state = _bridgeState(editing: true, hostName: 'Community00080'); + await tester.pumpWidget(_host(state, true)); + await tester.pumpAndSettle(); + + expect(find.textContaining('https://Community00080.local'), findsOneWidget); + + // Assert the hint is rendered bold. A textContaining check alone would not + // catch a regression back to the non-bold AppText.bodyMedium factory. + expect( + find.byWidgetPredicate((w) => + w is AppText && + w.data.contains('https://Community00080.local') && + w.fontWeight == FontWeight.bold), + findsOneWidget, + ); + }); + + testWidgets('hides the hint when not editing', (tester) async { + final state = _bridgeState(editing: false, hostName: 'Community00080'); + await tester.pumpWidget(_host(state, false)); + await tester.pumpAndSettle(); + + expect(find.textContaining('https://Community00080.local'), findsNothing); + }); + + testWidgets('hides the hint when hostname is empty', (tester) async { + final state = _bridgeState(editing: true, hostName: ''); + await tester.pumpWidget(_host(state, true)); + await tester.pumpAndSettle(); + + expect(find.textContaining('.local'), findsNothing); + }); +} diff --git a/test/page/internet_settings/views/usp_internet_settings_view_save_test.dart b/test/page/internet_settings/views/usp_internet_settings_view_save_test.dart new file mode 100644 index 000000000..721056aa9 --- /dev/null +++ b/test/page/internet_settings/views/usp_internet_settings_view_save_test.dart @@ -0,0 +1,51 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:privacy_gui/page/internet_settings/models/usp_wan_connection_type.dart'; +import 'package:privacy_gui/page/internet_settings/views/usp_internet_settings_view.dart'; + +void main() { + group('shouldRedirectToBridge', () { + test('true when entering bridge with a hostname', () { + expect( + shouldRedirectToBridge( + previousType: UspWanConnectionType.dhcp, + newType: UspWanConnectionType.bridge, + hostName: 'Community00080', + ), + isTrue, + ); + }); + + test('false when already in bridge (no transition)', () { + expect( + shouldRedirectToBridge( + previousType: UspWanConnectionType.bridge, + newType: UspWanConnectionType.bridge, + hostName: 'Community00080', + ), + isFalse, + ); + }); + + test('false when leaving bridge', () { + expect( + shouldRedirectToBridge( + previousType: UspWanConnectionType.bridge, + newType: UspWanConnectionType.dhcp, + hostName: 'Community00080', + ), + isFalse, + ); + }); + + test('false when entering bridge but hostname is empty', () { + expect( + shouldRedirectToBridge( + previousType: UspWanConnectionType.dhcp, + newType: UspWanConnectionType.bridge, + hostName: '', + ), + isFalse, + ); + }); + }); +} diff --git a/test/page/local_network/providers/dhcp_data_provider_test.dart b/test/page/local_network/providers/dhcp_data_provider_test.dart index 7bcf4f025..654b726a0 100644 --- a/test/page/local_network/providers/dhcp_data_provider_test.dart +++ b/test/page/local_network/providers/dhcp_data_provider_test.dart @@ -9,6 +9,9 @@ import 'package:privacy_gui/core/usp/providers/sse_invalidation_provider.dart'; import 'package:privacy_gui/core/usp/providers/usp_mutation_lock.dart'; import 'package:privacy_gui/core/usp/providers/usp_client_provider.dart'; import 'package:privacy_gui/core/usp/services/usp_client.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_network.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; import 'package:privacy_gui/page/local_network/providers/dhcp_data_provider.dart'; @@ -90,7 +93,7 @@ void main() { uspClientProvider.overrideWithValue(mockUsp), uspMutationLockProvider.overrideWithValue(UspMutationLock()), devicesDataProvider.overrideWith( - () => _TestDevicesDataNotifier(devicesData ?? const DevicesData()), + () => _TestDevicesDataNotifier(devicesData ?? _emptyDevicesData()), ), ], ); @@ -107,8 +110,8 @@ void main() { // Verify client fields expect(data.clientModels[0].mac, 'AA:BB:CC:DD:EE:01'); expect(data.clientModels[0].ip, '192.168.1.101'); - expect(data.clientModels[0].active, isTrue); - expect(data.clientModels[1].active, isFalse); + expect(data.clientModels[0].leaseActive, isTrue); + expect(data.clientModels[1].leaseActive, isFalse); // Verify reservation fields expect(data.reservationModels[0].mac, 'AA:BB:CC:DD:EE:01'); @@ -119,7 +122,7 @@ void main() { test('hostname enrichment from devicesData', () async { final container = createContainer( - devicesData: const DevicesData( + devicesData: _emptyDevicesData( hostNameByMac: {'AA:BB:CC:DD:EE:01': 'MyLaptop'}, ), ); @@ -134,12 +137,64 @@ void main() { container.dispose(); }); + test('isOnline enrichment from devicesData client devices', () async { + final container = createContainer( + devicesData: DevicesData( + hostNameByMac: const {'AA:BB:CC:DD:EE:01': 'MyLaptop'}, + meshNetwork: MeshNetwork( + master: MasterNode( + deviceId: 'GATEWAY', + model: 'TestRouter', + connectedClients: [ + ClientDevice( + mac: 'AA:BB:CC:DD:EE:01', + ip: '192.168.1.101', + hostName: 'MyLaptop', + isActive: true, + connectionType: ConnectionType.wifi, + ), + ClientDevice( + mac: 'AA:BB:CC:DD:EE:02', + ip: '192.168.1.102', + hostName: '', + isActive: false, + connectionType: ConnectionType.wired, + ), + ], + ), + ), + ), + ); + await container.read(devicesDataProvider.future); + final data = await container.read(dhcpDataProvider.future); + + // Client 1: online (from Hosts.Active) + expect(data.clientModels[0].isOnline, isTrue); + // Client 2: offline (from Hosts.Active) + expect(data.clientModels[1].isOnline, isFalse); + container.dispose(); + }); + + test('isOnline is null when no matching device in devicesData', () async { + // No client devices - no Hosts data available + final container = createContainer( + devicesData: _emptyDevicesData(), + ); + await container.read(devicesDataProvider.future); + final data = await container.read(dhcpDataProvider.future); + + // No matching device, isOnline should be null + expect(data.clientModels[0].isOnline, isNull); + expect(data.clientModels[1].isOnline, isNull); + container.dispose(); + }); + test('build throws when usp is null', () async { final container = ProviderContainer( overrides: [ uspClientProvider.overrideWithValue(null), devicesDataProvider.overrideWith( - () => _TestDevicesDataNotifier(const DevicesData()), + () => _TestDevicesDataNotifier(_emptyDevicesData()), ), ], ); @@ -167,13 +222,16 @@ void main() { container.dispose(); }); - test('DhcpData props uses lengths for equality', () async { + test('DhcpData equality via DiagnosticLoggable namedProps', () async { final container = createContainer(); final data1 = await container.read(dhcpDataProvider.future); final data2 = await container.read(dhcpDataProvider.future); expect(data1, equals(data2)); - expect(data1.props, [2, 1]); // 2 clients, 1 reservation + expect(data1.clientModels.length, 2); + expect(data1.reservationModels.length, 1); + // props is derived from namedProps.values (DiagnosticLoggable mixin). + expect(data1.props, [data1.clientModels, data1.reservationModels]); container.dispose(); }); @@ -186,7 +244,7 @@ void main() { uspClientProvider.overrideWithValue(mockUsp), uspMutationLockProvider.overrideWithValue(UspMutationLock()), devicesDataProvider.overrideWith( - () => _TestDevicesDataNotifier(const DevicesData()), + () => _TestDevicesDataNotifier(_emptyDevicesData()), ), sseInvalidationProvider.overrideWith((ref) => sseController.stream), ], @@ -223,7 +281,7 @@ void main() { uspClientProvider.overrideWithValue(mockUsp), uspMutationLockProvider.overrideWithValue(UspMutationLock()), devicesDataProvider.overrideWith( - () => _TestDevicesDataNotifier(const DevicesData()), + () => _TestDevicesDataNotifier(_emptyDevicesData()), ), sseInvalidationProvider.overrideWith((ref) => sseController.stream), ], @@ -255,7 +313,7 @@ void main() { uspClientProvider.overrideWithValue(mockUsp), uspMutationLockProvider.overrideWithValue(UspMutationLock()), devicesDataProvider.overrideWith( - () => _TestDevicesDataNotifier(const DevicesData()), + () => _TestDevicesDataNotifier(_emptyDevicesData()), ), sseInvalidationProvider.overrideWith((ref) => sseController.stream), ], @@ -288,3 +346,13 @@ class _TestDevicesDataNotifier extends DevicesDataNotifier { @override Future build() async => _data; } + +/// Creates an empty DevicesData for testing. +DevicesData _emptyDevicesData({Map? hostNameByMac}) { + return DevicesData( + meshNetwork: MeshNetwork( + master: MasterNode(deviceId: 'GATEWAY', model: 'TestRouter'), + ), + hostNameByMac: hostNameByMac ?? const {}, + ); +} diff --git a/test/page/local_network/providers/ethernet_data_provider_test.dart b/test/page/local_network/providers/ethernet_data_provider_test.dart index 03d181a92..4f6be73c6 100644 --- a/test/page/local_network/providers/ethernet_data_provider_test.dart +++ b/test/page/local_network/providers/ethernet_data_provider_test.dart @@ -3,8 +3,10 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/core/usp/providers/usp_client_provider.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; import 'package:privacy_gui/page/_shared/models/ethernet_port_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_network.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; import 'package:privacy_gui/page/local_network/providers/ethernet_data_provider.dart'; import 'package:privacy_gui/page/local_network/services/usp_ethernet_data_service.dart'; @@ -35,7 +37,7 @@ void main() { ]; setUpAll(() { - registerFallbackValue([]); + registerFallbackValue([]); }); setUp(() { @@ -55,7 +57,7 @@ void main() { overrides: [ uspEthernetDataServiceProvider.overrideWithValue(mockEthernetSvc), devicesDataProvider.overrideWith( - () => _TestDevicesDataNotifier(devicesData ?? const DevicesData()), + () => _TestDevicesDataNotifier(devicesData ?? _emptyDevicesData()), ), ], ); @@ -80,7 +82,7 @@ void main() { overrides: [ uspClientProvider.overrideWithValue(null), devicesDataProvider.overrideWith( - () => _TestDevicesDataNotifier(const DevicesData()), + () => _TestDevicesDataNotifier(_emptyDevicesData()), ), ], ); @@ -128,3 +130,12 @@ class _TestDevicesDataNotifier extends DevicesDataNotifier { @override Future build() async => _data; } + +/// Creates an empty DevicesData for testing. +DevicesData _emptyDevicesData() { + return DevicesData( + meshNetwork: MeshNetwork( + master: MasterNode(deviceId: 'GATEWAY', model: 'TestRouter'), + ), + ); +} diff --git a/test/page/local_network/providers/lan_data_provider_test.dart b/test/page/local_network/providers/lan_data_provider_test.dart index 46410300a..b5cc7bce1 100644 --- a/test/page/local_network/providers/lan_data_provider_test.dart +++ b/test/page/local_network/providers/lan_data_provider_test.dart @@ -25,6 +25,8 @@ void main() { }; /// IPv6 response from raw usp.get(). + /// Includes a link-local (fe80::) address (kept but reordered after global, + /// tagged by the UI) and a global address that must be preferred first (#1129). final ipv6Response = { 'Device.IP.Interface.1.IPv6Enable': true, 'Device.IP.Interface.1.IPv6Address.1.IPAddress': 'fe80::1', @@ -65,7 +67,81 @@ void main() { expect(data.model.dnsServers, '8.8.8.8,8.8.4.4'); expect(data.model.hostName, 'LinksysRouter'); expect(data.model.ipv6Enabled, isTrue); - expect(data.model.ipv6Addresses, ['fe80::1', '2001:db8::1']); + // #1129: link-local fe80:: is kept but reordered after the global address + // (the UI tags it with a scope badge rather than hiding it). + expect(data.model.ipv6Addresses, ['2001:db8::1', 'fe80::1']); + container.dispose(); + }); + + test('link-local-only IPv6 keeps the link-local address (#1129)', () async { + // Reproduces the reported case: br-lan holds only a scope-link fe80:: + // address and no global/ULA prefix. It is still surfaced (tagged with a + // scope badge by the UI), not hidden. + when(() => mockUsp.get(any())).thenAnswer((_) async { + final paths = _.positionalArguments[0] as List; + if (paths.any((p) => p.toString().contains('IPv6Address'))) { + return { + 'Device.IP.Interface.1.IPv6Enable': true, + 'Device.IP.Interface.1.IPv6Address.1.IPAddress': + 'fe80::7612:13ff:fe21:5394', + }; + } + return lanInfoResponse; + }); + + final container = createContainer(); + final data = await container.read(lanDataProvider.future); + + expect(data.model.ipv6Enabled, isTrue); + expect(data.model.ipv6Addresses, ['fe80::7612:13ff:fe21:5394']); + container.dispose(); + }); + + test('link-local reordered after global, all kept (#1129)', () async { + when(() => mockUsp.get(any())).thenAnswer((_) async { + final paths = _.positionalArguments[0] as List; + if (paths.any((p) => p.toString().contains('IPv6Address'))) { + return { + 'Device.IP.Interface.1.IPv6Enable': true, + 'Device.IP.Interface.1.IPv6Address.1.IPAddress': 'fe80::1%eth0', + 'Device.IP.Interface.1.IPv6Address.2.IPAddress': 'febf::1', + 'Device.IP.Interface.1.IPv6Address.3.IPAddress': '2001:db8:abcd::5', + }; + } + return lanInfoResponse; + }); + + final container = createContainer(); + final data = await container.read(lanDataProvider.future); + + // fe80::1%eth0 (zone index) and febf::1 (top of fe80::/10) are link-local; + // all addresses are kept, with the global one surfaced first. + expect(data.model.ipv6Addresses, + ['2001:db8:abcd::5', 'fe80::1%eth0', 'febf::1']); + container.dispose(); + }); + + test('fec0::1 is NOT link-local (#1129)', () async { + // fec0::1 is the first address just above the fe80::/10 range + // (link-local spans fe80::-febf::). It is a deprecated site-local + // address (RFC 3513), NOT link-local: + // 0xfec0 & 0xffc0 == 0xfec0 != 0xfe80 + // It is kept and, not being link-local, is not tagged as such by the UI. + when(() => mockUsp.get(any())).thenAnswer((_) async { + final paths = _.positionalArguments[0] as List; + if (paths.any((p) => p.toString().contains('IPv6Address'))) { + return { + 'Device.IP.Interface.1.IPv6Enable': true, + 'Device.IP.Interface.1.IPv6Address.1.IPAddress': 'fec0::1', + }; + } + return lanInfoResponse; + }); + + final container = createContainer(); + final data = await container.read(lanDataProvider.future); + + expect(data.model.ipv6Addresses, ['fec0::1']); container.dispose(); }); diff --git a/test/page/local_network/providers/usp_local_network_notifier_test.dart b/test/page/local_network/providers/usp_local_network_notifier_test.dart index 6c48caf2a..384e38c2a 100644 --- a/test/page/local_network/providers/usp_local_network_notifier_test.dart +++ b/test/page/local_network/providers/usp_local_network_notifier_test.dart @@ -2,24 +2,34 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:privacy_gui/core/errors/service_error.dart'; +import 'package:privacy_gui/core/usp/providers/sse_providers.dart'; import 'package:privacy_gui/core/usp/providers/usp_mutation_lock.dart'; +import 'package:privacy_gui/core/usp/services/sse_manager.dart'; import 'package:privacy_gui/page/_shared/models/lan_info_ui_model.dart'; import 'package:privacy_gui/page/local_network/models/local_network_ui_model.dart'; import 'package:privacy_gui/page/local_network/providers/lan_data_provider.dart'; +import 'package:privacy_gui/core/usp/services/usp_client.dart'; import 'package:privacy_gui/page/local_network/providers/usp_local_network_notifier.dart'; import 'package:privacy_gui/page/local_network/services/usp_local_network_service.dart'; class MockUspLocalNetworkService extends Mock implements UspLocalNetworkService {} -/// Test-only notifier that returns canned data. +class MockSseManager extends Mock implements SseManager {} + +class MockUspClient extends Mock implements UspClient {} + +/// Test-only notifier that returns canned data and counts build() runs so +/// tests can assert whether save() triggered a post-save re-fetch. class _TestLanDataNotifier extends LanDataNotifier { final LanData _testData; final ServiceError? errorToThrow; + int buildCount = 0; _TestLanDataNotifier(this._testData, {this.errorToThrow}); @override Future build() async { + buildCount++; if (errorToThrow != null) throw errorToThrow!; return _testData; } @@ -27,6 +37,7 @@ class _TestLanDataNotifier extends LanDataNotifier { void main() { late MockUspLocalNetworkService mockService; + late MockSseManager mockSseManager; final testLanData = LanData( model: LanInfoUIModel( @@ -47,10 +58,12 @@ void main() { setUp(() { mockService = MockUspLocalNetworkService(); + mockSseManager = MockSseManager(); when(() => mockService.validateAll(any())).thenReturn({}); when(() => mockService.lockedOctetCount(any())).thenReturn(3); when(() => mockService.syncPrefix(any(), any(), any())) .thenAnswer((inv) => inv.positionalArguments[0] as String); + when(() => mockSseManager.disconnect()).thenAnswer((_) async {}); }); ProviderContainer createContainer({LanData? data}) { @@ -58,6 +71,7 @@ void main() { overrides: [ uspLocalNetworkServiceProvider.overrideWithValue(mockService), uspMutationLockProvider.overrideWithValue(UspMutationLock()), + sseManagerProvider.overrideWithValue(mockSseManager), lanDataProvider .overrideWith(() => _TestLanDataNotifier(data ?? testLanData)), ], @@ -94,14 +108,15 @@ void main() { container.dispose(); }); - test('updateSetting triggers validation', () async { + test('updateSetting mutates model without validation', () async { final container = createContainer(); await Future.delayed(Duration.zero); final notifier = container.read(uspLocalNetworkProvider.notifier); notifier.updateSetting((m) => m.copyWith(hostName: 'NewName')); - verify(() => mockService.validateAll(any())).called(1); + // updateSetting no longer calls validateAll — validation is separate + verifyNever(() => mockService.validateAll(any())); final state = container.read(uspLocalNetworkProvider); expect(state.settings.current.model.hostName, 'NewName'); container.dispose(); @@ -124,7 +139,7 @@ void main() { container.dispose(); }); - test('updateSetting propagates validation errors to status', () async { + test('validate propagates validation errors to status', () async { when(() => mockService.validateAll(any())) .thenReturn({'hostName': 'Too long'}); final container = createContainer(); @@ -132,6 +147,7 @@ void main() { final notifier = container.read(uspLocalNetworkProvider.notifier); notifier.updateSetting((m) => m.copyWith(hostName: 'VeryLongHostNameX')); + notifier.validate(); final state = container.read(uspLocalNetworkProvider); expect(state.status.validationErrors['hostName'], 'Too long'); @@ -146,6 +162,7 @@ void main() { final notifier = container.read(uspLocalNetworkProvider.notifier); notifier.updateSetting((m) => m.copyWith(hostName: 'X')); + notifier.validate(); expect( container .read(uspLocalNetworkProvider) @@ -230,5 +247,125 @@ void main() { expect(container.read(uspLocalNetworkProvider).status.isSaving, isFalse); container.dispose(); }); + + test('save on IP change disconnects SSE and skips the post-save re-fetch', + () async { + when(() => mockService.save( + original: any(named: 'original'), + pending: any(named: 'pending'), + )).thenAnswer((_) async {}); + final container = createContainer(); + await Future.delayed(Duration.zero); + + final lanNotifier = + container.read(lanDataProvider.notifier) as _TestLanDataNotifier; + // build() ran once during initial fetch. + expect(lanNotifier.buildCount, 1); + + final notifier = container.read(uspLocalNetworkProvider.notifier); + notifier.updateSetting((m) => m.copyWith(ipAddress: '192.168.5.1')); + await notifier.save(); + + // SSE is dropped so the recovery flow never fires on top of the redirect + // dialog. + verify(() => mockSseManager.disconnect()).called(1); + // No re-fetch: the router is unreachable at the old IP, so lanDataProvider + // must NOT be invalidated/rebuilt (a re-fetch would time out). + expect(lanNotifier.buildCount, 1); + container.dispose(); + }); + + test('save on non-network change re-fetches and does not disconnect SSE', + () async { + when(() => mockService.save( + original: any(named: 'original'), + pending: any(named: 'pending'), + )).thenAnswer((_) async {}); + final container = createContainer(); + await Future.delayed(Duration.zero); + + final lanNotifier = + container.read(lanDataProvider.notifier) as _TestLanDataNotifier; + expect(lanNotifier.buildCount, 1); + + final notifier = container.read(uspLocalNetworkProvider.notifier); + // Only a DHCP field changes — IP/subnet stay the same. + notifier.updateSetting((m) => m.copyWith(dnsServer1: '9.9.9.9')); + await notifier.save(); + await Future.delayed(Duration.zero); + + verifyNever(() => mockSseManager.disconnect()); + // lanDataProvider is invalidated → build() runs a second time. + expect(lanNotifier.buildCount, 2); + container.dispose(); + }); + }); + + // Regression: pool prefix auto-sync must follow BOTH the router IP and the + // subnet mask. Uses the REAL service so the pure lockedOctetCount/syncPrefix + // logic runs, reproducing the dead-end from issue #1039 follow-up. + group('UspLocalNetworkNotifier — pool prefix auto-sync (real service)', () { + // Initial: ip=192.168.1.1, mask=255.255.0.0 (/16), pool=192.168.1.x + final startLanData = LanData( + model: LanInfoUIModel( + hostName: 'MyRouter', + ipAddress: '192.168.1.1', + subnetMask: '255.255.0.0', + dhcpEnabled: true, + minAddress: '192.168.1.100', + maxAddress: '192.168.1.200', + leaseTimeMinutes: 1440, + dnsServers: '1.1.1.1', + ), + ); + + ProviderContainer createRealContainer() { + final container = ProviderContainer( + overrides: [ + uspLocalNetworkServiceProvider + .overrideWithValue(UspLocalNetworkService(MockUspClient())), + uspMutationLockProvider.overrideWithValue(UspMutationLock()), + sseManagerProvider.overrideWithValue(mockSseManager), + lanDataProvider + .overrideWith(() => _TestLanDataNotifier(startLanData)), + ], + ); + container.listen(uspLocalNetworkProvider, (_, __) {}); + return container; + } + + test( + 'tightening the mask (/16 → /24) re-syncs pool prefix to the router IP ' + 'so locked octets never go out-of-subnet', () async { + final container = createRealContainer(); + await Future.delayed(Duration.zero); + + final notifier = container.read(uspLocalNetworkProvider.notifier); + + // 1. Change IP to 192.168.2.1 (mask still /16 → locks 2 octets, so the + // 3rd octet of the pool stays .1 and is still in-subnet). + notifier.updateSetting((m) => m.copyWith(ipAddress: '192.168.2.1')); + var model = + container.read(uspLocalNetworkProvider).settings.current.model; + expect(model.minAddress, '192.168.1.100'); + expect(model.maxAddress, '192.168.1.200'); + + // 2. Tighten the mask to /24. lockedOctetCount becomes 3, so the UI would + // lock the pool's 3rd octet read-only. The pool MUST be re-synced to + // the router's 3rd octet (2), otherwise it stays 192.168.1.x — + // out-of-subnet AND uneditable (the reported dead-end). + notifier.updateSetting((m) => m.copyWith(subnetMask: '255.255.255.0')); + model = container.read(uspLocalNetworkProvider).settings.current.model; + expect(model.minAddress, '192.168.2.100'); + expect(model.maxAddress, '192.168.2.200'); + + // Locked prefix and pool now agree → validation passes for the pool. + notifier.validate(); + final errors = + container.read(uspLocalNetworkProvider).status.validationErrors; + expect(errors['minAddress'], isNull); + expect(errors['maxAddress'], isNull); + container.dispose(); + }); }); } diff --git a/test/page/local_network/services/usp_ethernet_data_service_test.dart b/test/page/local_network/services/usp_ethernet_data_service_test.dart index 63d6152f6..2dbe453e4 100644 --- a/test/page/local_network/services/usp_ethernet_data_service_test.dart +++ b/test/page/local_network/services/usp_ethernet_data_service_test.dart @@ -2,7 +2,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/core/usp/services/usp_client.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; import 'package:privacy_gui/page/local_network/services/usp_ethernet_data_service.dart'; class MockUspClient extends Mock implements UspClient {} @@ -11,19 +11,19 @@ class MockUspClient extends Mock implements UspClient {} // Test helpers // --------------------------------------------------------------------------- -DeviceUIModel _device({ +ClientDevice _device({ String mac = 'AA:BB:CC:DD:EE:FF', String ip = '192.168.1.10', String hostName = 'laptop', bool isActive = true, bool isWifi = false, }) => - DeviceUIModel( + ClientDevice( mac: mac, ip: ip, hostName: hostName, isActive: isActive, - isWifi: isWifi, + connectionType: isWifi ? ConnectionType.wifi : ConnectionType.wired, ); /// Ethernet interfaces response (2 interfaces). diff --git a/test/page/local_network/services/usp_local_network_service_test.dart b/test/page/local_network/services/usp_local_network_service_test.dart index d314fd283..eb462a332 100644 --- a/test/page/local_network/services/usp_local_network_service_test.dart +++ b/test/page/local_network/services/usp_local_network_service_test.dart @@ -1,3 +1,6 @@ +import 'dart:async'; + +import 'package:fake_async/fake_async.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:privacy_gui/core/errors/service_error.dart'; @@ -433,33 +436,38 @@ void main() { test('save succeeds when firmware returns success', () async { when(() => mockUsp.get(any())).thenAnswer((_) async => aliasResponse); - when(() => mockUsp.set(any())).thenAnswer((_) async => { - 'success': true, - 'result': {'data': {}}, - }); + when(() => mockUsp.set(any(), allowPartial: any(named: 'allowPartial'))) + .thenAnswer((_) async => { + 'success': true, + 'result': {'data': {}}, + }); await service.save( original: _model(), pending: _model(hostName: 'NewRouter'), ); - verify(() => mockUsp.set(any())).called(1); + // LAN settings span multiple USP Services (Device.IP + Device.DHCPv4 + + // Device.DeviceInfo). The save MUST pass allowPartial: true, otherwise + // firmware rejects the cross-service atomic SET with error 7005 (#1039). + verify(() => mockUsp.set(any(), allowPartial: true)).called(1); }); test('save throws UspCompleteFailureError on firmware failure', () async { when(() => mockUsp.get(any())).thenAnswer((_) async => aliasResponse); - when(() => mockUsp.set(any())).thenAnswer((_) async => { - 'success': false, - 'result': { - 'data': {}, - 'error': { - 'Device.DHCPv4.Server.Pool.1.MinAddress': { - 'errorCode': 7004, - 'errorMessage': 'Parameter not writable' - } - } - }, - }); + when(() => mockUsp.set(any(), allowPartial: any(named: 'allowPartial'))) + .thenAnswer((_) async => { + 'success': false, + 'result': { + 'data': {}, + 'error': { + 'Device.DHCPv4.Server.Pool.1.MinAddress': { + 'errorCode': 7004, + 'errorMessage': 'Parameter not writable' + } + } + }, + }); expect( () => service.save( @@ -472,20 +480,21 @@ void main() { test('save throws UspPartialFailureError on partial success', () async { when(() => mockUsp.get(any())).thenAnswer((_) async => aliasResponse); - when(() => mockUsp.set(any())).thenAnswer((_) async => { - 'success': true, - 'result': { - 'data': { - 'Device.DHCPv4.Server.Pool.1.MinAddress': '192.168.1.50' - }, - 'error': { - 'Device.DHCPv4.Server.Pool.1.MaxAddress': { - 'errorCode': 7004, - 'errorMessage': 'Parameter not writable' - } - } - }, - }); + when(() => mockUsp.set(any(), allowPartial: any(named: 'allowPartial'))) + .thenAnswer((_) async => { + 'success': true, + 'result': { + 'data': { + 'Device.DHCPv4.Server.Pool.1.MinAddress': '192.168.1.50' + }, + 'error': { + 'Device.DHCPv4.Server.Pool.1.MaxAddress': { + 'errorCode': 7004, + 'errorMessage': 'Parameter not writable' + } + } + }, + }); expect( () => service.save( @@ -499,7 +508,7 @@ void main() { test('save maps transport error to ServiceError', () async { when(() => mockUsp.get(any())).thenAnswer((_) async => aliasResponse); - when(() => mockUsp.set(any())) + when(() => mockUsp.set(any(), allowPartial: any(named: 'allowPartial'))) .thenThrow('Set failed: Transport error: Connection refused'); expect( @@ -510,5 +519,78 @@ void main() { throwsA(isA()), ); }); + + // --- Terminal SET on IP address change ------------------------------- + // Changing the router IP makes the firmware drop the connection carrying + // the SET response. A timeout / transport error is then the expected + // signature of success, but a fault the router actively returns still fails. + + test('save on IP change treats a Dart timeout as success', () { + // The SET response can never arrive (connection gone). Drive the 4s + // budget with fake_async so the test does not actually wait. + fakeAsync((async) { + when(() => mockUsp.get(any())).thenAnswer((_) async => aliasResponse); + when(() => mockUsp.set(any(), allowPartial: any(named: 'allowPartial'))) + .thenAnswer((_) => Completer>().future); + + Object? error; + var done = false; + service + .save( + original: _model(ipAddress: '192.168.1.1'), + pending: _model(ipAddress: '192.168.2.1'), + ) + .then((_) => done = true, onError: (Object e) => error = e); + + async.elapse(const Duration(seconds: 5)); + + expect(error, isNull, + reason: 'an IP-change SET timeout must be treated as success'); + expect(done, isTrue); + }); + }); + + test('save on IP change treats a transport error as success', () async { + when(() => mockUsp.get(any())).thenAnswer((_) async => aliasResponse); + when(() => mockUsp.set(any(), allowPartial: any(named: 'allowPartial'))) + .thenThrow('Set failed: Transport error: Connection refused'); + + // A transport error on an IP-changing SET is the disconnect signature → + // success. (Contrast with the hostName-only case above, which surfaces.) + await expectLater( + service.save( + original: _model(ipAddress: '192.168.1.1'), + pending: _model(ipAddress: '192.168.2.1'), + ), + completes, + ); + }); + + test('save on IP change still rethrows a real firmware fault', () async { + when(() => mockUsp.get(any())).thenAnswer((_) async => aliasResponse); + // Router actively rejected the SET BEFORE any disconnect — a genuine + // config failure that must reach the user, never swallowed. + when(() => mockUsp.set(any(), allowPartial: any(named: 'allowPartial'))) + .thenAnswer((_) async => { + 'success': false, + 'result': { + 'data': {}, + 'error': { + 'Device.IP.Interface.1.IPv4Address.1.IPAddress': { + 'errorCode': 7006, + 'errorMessage': 'Invalid value', + }, + }, + }, + }); + + await expectLater( + service.save( + original: _model(ipAddress: '192.168.1.1'), + pending: _model(ipAddress: '192.168.2.1'), + ), + throwsA(isA()), + ); + }); }); } diff --git a/test/page/local_network/views/helpers/lan_ip_redirect_dialog_test.dart b/test/page/local_network/views/helpers/lan_ip_redirect_dialog_test.dart new file mode 100644 index 000000000..afc83a808 --- /dev/null +++ b/test/page/local_network/views/helpers/lan_ip_redirect_dialog_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:privacy_gui/l10n/gen/app_localizations.dart'; +import 'package:privacy_gui/page/local_network/views/helpers/lan_ip_redirect_dialog.dart'; +import 'package:ui_kit_library/ui_kit.dart'; + +final _testTheme = AppTheme.create( + brightness: Brightness.light, + seedColor: Colors.blue, + designThemeBuilder: (c) => CustomDesignTheme.fromJson({ + 'style': 'flat', + }), +); + +Widget _harness({required void Function(String) navigate}) { + return MaterialApp( + theme: _testTheme, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Builder( + builder: (context) => ElevatedButton( + onPressed: () => showLanIpRedirectDialog( + context, + hostName: 'MyRouter', + navigate: navigate, + ), + child: const Text('open'), + ), + ), + ), + ); +} + +void main() { + testWidgets('shows the .local management address', (tester) async { + await tester.pumpWidget(_harness(navigate: (_) {})); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + expect( + find.textContaining('https://MyRouter.local'), + findsWidgets, + ); + }); + + testWidgets('go button navigates to the .local URL', (tester) async { + String? navigated; + await tester.pumpWidget(_harness(navigate: (url) => navigated = url)); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Go to router')); + await tester.pumpAndSettle(); + + expect(navigated, 'https://MyRouter.local'); + }); + + testWidgets('offers no dismiss/close action — redirect is the only path', + (tester) async { + await tester.pumpWidget(_harness(navigate: (_) {})); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + // Once the LAN IP changed the router is unreachable on this origin, so the + // dialog must not offer a way to stay on the (now-dead) page. + expect(find.text('Go to router'), findsOneWidget); + expect(find.text('Close'), findsNothing); + }); +} diff --git a/test/page/port_forwarding/services/usp_port_forwarding_service_test.dart b/test/page/port_forwarding/services/usp_port_forwarding_service_test.dart index 985a23856..69d0dfd08 100644 --- a/test/page/port_forwarding/services/usp_port_forwarding_service_test.dart +++ b/test/page/port_forwarding/services/usp_port_forwarding_service_test.dart @@ -663,6 +663,195 @@ void main() { expect(result.updated, 1); }); + // ------------------------------------------------------------------------- + // #1061 regression: editing the forwarded-port of an EXISTING trigger must + // persist. Before the fix, saveTriggeringBatch only patched parent-level + // fields and never touched the nested Rule.* sub-table, so the edit was + // silently dropped. + // ------------------------------------------------------------------------- + + test('#1061: editing forwarded-port of existing rule issues Set on Rule.*', + () async { + when(() => mockUsp.set(any(), allowPartial: any(named: 'allowPartial'))) + .thenAnswer((_) async => uspSuccess()); + + final original = [ + PortTriggeringRuleUIModel( + instancePath: 'Device.NAT.PortTrigger.1.', + enabled: true, + description: 'FTP', + triggerPort: 21, + triggerProtocol: 'TCP', + forwardRules: const [ + PortTriggerForwardRuleUIModel( + instancePath: 'Device.NAT.PortTrigger.1.Rule.1.', + forwardPort: 1024, + forwardPortEndRange: 1030, + forwardProtocol: 'TCP', + ), + ], + ), + ]; + final current = [ + PortTriggeringRuleUIModel( + instancePath: 'Device.NAT.PortTrigger.1.', + enabled: true, + description: 'FTP', + triggerPort: 21, + triggerProtocol: 'TCP', + forwardRules: const [ + PortTriggerForwardRuleUIModel( + instancePath: 'Device.NAT.PortTrigger.1.Rule.1.', + forwardPort: 2048, // changed + forwardPortEndRange: 2050, // changed + forwardProtocol: 'UDP', // changed + ), + ], + ), + ]; + + await service.saveTriggeringBatch(original: original, current: current); + + // The forward-rule edit is reconciled via an in-place Set carrying the + // Rule.{j} param paths with the NEW values. + final captured = verify(() => mockUsp.set(captureAny())).captured; + expect(captured, hasLength(1)); + final params = captured.first as Map; + expect(params['Device.NAT.PortTrigger.1.Rule.1.Port'], 2048); + expect(params['Device.NAT.PortTrigger.1.Rule.1.PortEndRange'], 2050); + expect(params['Device.NAT.PortTrigger.1.Rule.1.Protocol'], 'UDP'); + }); + + test('#1061: adding a forward rule to existing trigger calls add on Rule.', + () async { + when(() => mockUsp.add(any())).thenAnswer( + (_) async => uspAddSuccess(['Device.NAT.PortTrigger.1.Rule.2.'])); + + final original = [ + PortTriggeringRuleUIModel( + instancePath: 'Device.NAT.PortTrigger.1.', + enabled: true, + description: 'FTP', + triggerPort: 21, + triggerProtocol: 'TCP', + forwardRules: const [ + PortTriggerForwardRuleUIModel( + instancePath: 'Device.NAT.PortTrigger.1.Rule.1.', + forwardPort: 1024, + forwardProtocol: 'TCP', + ), + ], + ), + ]; + final current = [ + PortTriggeringRuleUIModel( + instancePath: 'Device.NAT.PortTrigger.1.', + enabled: true, + description: 'FTP', + triggerPort: 21, + triggerProtocol: 'TCP', + forwardRules: const [ + PortTriggerForwardRuleUIModel( + instancePath: 'Device.NAT.PortTrigger.1.Rule.1.', + forwardPort: 1024, + forwardProtocol: 'TCP', + ), + PortTriggerForwardRuleUIModel( + // new local rule, no instancePath yet + forwardPort: 3000, + forwardProtocol: 'UDP', + ), + ], + ), + ]; + + await service.saveTriggeringBatch(original: original, current: current); + + final captured = verify(() => mockUsp.add(captureAny())).captured; + expect(captured, hasLength(1)); + final items = captured.first as List; + expect(items.first['path'], 'Device.NAT.PortTrigger.1.Rule.'); + expect(items.first['params']['Port'], 3000); + expect(items.first['params']['Protocol'], 'UDP'); + }); + + test('#1061: removing a forward rule from existing trigger calls delete', + () async { + when(() => mockUsp.delete(any())).thenAnswer((_) async => uspSuccess()); + + final original = [ + PortTriggeringRuleUIModel( + instancePath: 'Device.NAT.PortTrigger.1.', + enabled: true, + description: 'FTP', + triggerPort: 21, + triggerProtocol: 'TCP', + forwardRules: const [ + PortTriggerForwardRuleUIModel( + instancePath: 'Device.NAT.PortTrigger.1.Rule.1.', + forwardPort: 1024, + forwardProtocol: 'TCP', + ), + PortTriggerForwardRuleUIModel( + instancePath: 'Device.NAT.PortTrigger.1.Rule.2.', + forwardPort: 3000, + forwardProtocol: 'UDP', + ), + ], + ), + ]; + final current = [ + PortTriggeringRuleUIModel( + instancePath: 'Device.NAT.PortTrigger.1.', + enabled: true, + description: 'FTP', + triggerPort: 21, + triggerProtocol: 'TCP', + forwardRules: const [ + PortTriggerForwardRuleUIModel( + instancePath: 'Device.NAT.PortTrigger.1.Rule.1.', + forwardPort: 1024, + forwardProtocol: 'TCP', + ), + ], + ), + ]; + + await service.saveTriggeringBatch(original: original, current: current); + + final captured = verify(() => mockUsp.delete(captureAny())).captured; + expect(captured, hasLength(1)); + expect(captured.first, ['Device.NAT.PortTrigger.1.Rule.2.']); + }); + + test('#1061: unchanged forward rules issue no Rule.* operations', () async { + when(() => mockUsp.set(any(), allowPartial: any(named: 'allowPartial'))) + .thenAnswer((_) async => uspSuccess()); + + final rule = PortTriggeringRuleUIModel( + instancePath: 'Device.NAT.PortTrigger.1.', + enabled: true, + description: 'FTP', + triggerPort: 21, + triggerProtocol: 'TCP', + forwardRules: const [ + PortTriggerForwardRuleUIModel( + instancePath: 'Device.NAT.PortTrigger.1.Rule.1.', + forwardPort: 1024, + forwardProtocol: 'TCP', + ), + ], + ); + // Only the parent-level field (enabled) changes; forward rules identical. + final current = [rule.copyWith(enabled: false)]; + + await service.saveTriggeringBatch(original: [rule], current: current); + + // Parent Set fires once (Enable). No forward Set/add/delete. + verifyNever(() => mockUsp.add(any())); + verifyNever(() => mockUsp.delete(any())); + }); + test('mixed batch: delete + add + update', () async { when(() => mockUsp.delete(any())).thenAnswer((_) async => uspSuccess()); when(() => mockUsp.add(any())).thenAnswer( diff --git a/test/page/static_routing/services/usp_static_routing_service_test.dart b/test/page/static_routing/services/usp_static_routing_service_test.dart index 174650bf7..16e6ebb3a 100644 --- a/test/page/static_routing/services/usp_static_routing_service_test.dart +++ b/test/page/static_routing/services/usp_static_routing_service_test.dart @@ -152,7 +152,7 @@ void main() { subnetMask: '255.255.255.0', gateway: '', ); - expect(errors['name'], 'Name is required'); + expect(errors['name'], StaticRoutingErrorKeys.nameRequired); }); test('name over 32 chars returns error', () { @@ -162,7 +162,7 @@ void main() { subnetMask: '255.255.255.0', gateway: '', ); - expect(errors['name'], 'Name must be 32 characters or less'); + expect(errors['name'], StaticRoutingErrorKeys.nameTooLong); }); test('name exactly 32 chars is valid', () { @@ -182,7 +182,7 @@ void main() { subnetMask: '255.255.255.0', gateway: '', ); - expect(errors['destIp'], 'Destination IP is required'); + expect(errors['destIp'], StaticRoutingErrorKeys.destIpRequired); }); test('invalid destIp returns error', () { @@ -192,7 +192,7 @@ void main() { subnetMask: '255.255.255.0', gateway: '', ); - expect(errors['destIp'], 'Invalid IP address'); + expect(errors['destIp'], StaticRoutingErrorKeys.invalidIpAddress); }); test('empty subnetMask returns error', () { @@ -202,7 +202,7 @@ void main() { subnetMask: '', gateway: '', ); - expect(errors['subnetMask'], 'Subnet mask is required'); + expect(errors['subnetMask'], StaticRoutingErrorKeys.subnetMaskRequired); }); test('invalid subnetMask returns error', () { @@ -212,7 +212,7 @@ void main() { subnetMask: '255.255.0.128', gateway: '', ); - expect(errors['subnetMask'], 'Invalid subnet mask'); + expect(errors['subnetMask'], StaticRoutingErrorKeys.invalidSubnetMask); }); test('empty gateway is valid (optional)', () { @@ -232,7 +232,7 @@ void main() { subnetMask: '255.255.255.0', gateway: 'not-an-ip', ); - expect(errors['gateway'], 'Invalid IP address'); + expect(errors['gateway'], StaticRoutingErrorKeys.invalidIpAddress); }); test('multiple errors returned simultaneously', () { @@ -244,6 +244,106 @@ void main() { ); expect(errors, hasLength(4)); }); + + // --- gateway subnet validation (issue #1082) --- + + test('LAN interface: gateway within LAN subnet is valid', () { + final errors = UspStaticRoutingService.validateRoute( + name: 'Route1', + destIp: '10.0.0.0', + subnetMask: '255.255.255.0', + gateway: '192.168.1.100', + interfaceName: 'LAN', + lanIp: '192.168.1.1', + lanSubnetMask: '255.255.255.0', + ); + expect(errors.containsKey('gateway'), isFalse); + }); + + test('LAN interface: gateway outside LAN subnet returns error', () { + final errors = UspStaticRoutingService.validateRoute( + name: 'Route1', + destIp: '10.0.0.0', + subnetMask: '255.255.255.0', + gateway: '8.8.8.8', + interfaceName: 'LAN', + lanIp: '192.168.1.1', + lanSubnetMask: '255.255.255.0', + ); + expect(errors['gateway'], + StaticRoutingErrorKeys.gatewayMustBeWithinLanSubnet); + }); + + test('Internet interface: gateway outside LAN subnet is valid', () { + final errors = UspStaticRoutingService.validateRoute( + name: 'Route1', + destIp: '10.0.0.0', + subnetMask: '255.255.255.0', + gateway: '100.64.1.1', + interfaceName: 'Internet', + lanIp: '192.168.1.1', + lanSubnetMask: '255.255.255.0', + ); + expect(errors.containsKey('gateway'), isFalse); + }); + + test('Internet interface: gateway within LAN subnet returns error', () { + final errors = UspStaticRoutingService.validateRoute( + name: 'Route1', + destIp: '10.0.0.0', + subnetMask: '255.255.255.0', + gateway: '192.168.1.50', + interfaceName: 'Internet', + lanIp: '192.168.1.1', + lanSubnetMask: '255.255.255.0', + ); + expect(errors['gateway'], + StaticRoutingErrorKeys.gatewayMustBeOutsideLanSubnet); + }); + + test('subnet validation skipped when lanIp/lanSubnetMask not provided', () { + final errors = UspStaticRoutingService.validateRoute( + name: 'Route1', + destIp: '10.0.0.0', + subnetMask: '255.255.255.0', + gateway: '8.8.8.8', + interfaceName: 'LAN', + ); + expect(errors.containsKey('gateway'), isFalse); + }); + + test( + 'empty-string lanIp/lanSubnetMask (LanData.empty fallback) skips subnet ' + 'check — no false-positive Internet error', () { + // Regression for W-NEW-1: LanData.empty() returns ipAddress:'' / + // subnetMask:'' (empty strings, not null). ipToNum('') == 0, so the LAN + // subnet rule would incorrectly report isInLan == true for ANY gateway, + // firing a permanent false-positive gatewayMustBeOutsideLanSubnet error + // on Internet-interface routes while LAN data is still loading. + final errors = UspStaticRoutingService.validateRoute( + name: 'Route1', + destIp: '10.0.0.0', + subnetMask: '255.255.255.0', + gateway: '8.8.8.8', + interfaceName: 'Internet', + lanIp: '', + lanSubnetMask: '', + ); + expect(errors.containsKey('gateway'), isFalse); + }); + + test('invalid gateway format takes precedence over subnet check', () { + final errors = UspStaticRoutingService.validateRoute( + name: 'Route1', + destIp: '10.0.0.0', + subnetMask: '255.255.255.0', + gateway: 'bad-ip', + interfaceName: 'LAN', + lanIp: '192.168.1.1', + lanSubnetMask: '255.255.255.0', + ); + expect(errors['gateway'], StaticRoutingErrorKeys.invalidIpAddress); + }); }); // --------------------------------------------------------------------------- diff --git a/test/page/topology/helpers/usp_topology_builder_test.dart b/test/page/topology/helpers/usp_topology_builder_test.dart index dc40738f8..388cc3c8c 100644 --- a/test/page/topology/helpers/usp_topology_builder_test.dart +++ b/test/page/topology/helpers/usp_topology_builder_test.dart @@ -1,16 +1,17 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:privacy_gui/page/_shared/utils/device_classifier.dart'; import 'package:privacy_gui/core/utils/oui_lookup.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_network.dart'; import 'package:privacy_gui/page/_shared/models/system_info_ui_model.dart'; -import 'package:privacy_gui/page/topology/models/node_ui_model.dart'; import 'package:privacy_gui/page/topology/helpers/usp_topology_builder.dart'; import 'package:ui_kit_library/ui_kit.dart'; +import '../../../mocks/test_data/devices_test_data.dart'; + void main() { - // OUI database for testing (minimal set for topology tests) + // OUI database for testing const testOuiDatabase = { '112233': 'Test Vendor', + 'AABBCC': 'Linksys', }; setUpAll(() { @@ -21,862 +22,534 @@ void main() { OuiLookup.reset(); }); - // --------------------------------------------------------------------------- - // Shared test data - // --------------------------------------------------------------------------- - const sysInfo = SystemInfoUIModel( manufacturer: 'Linksys', modelName: 'MR7500', - serialNumber: 'SN123', hardwareVersion: '1.0', - softwareVersion: '2.0.0', + serialNumber: 'SN123456', + softwareVersion: '1.0.16.26013014', uptime: 3600, totalMemory: 512000, freeMemory: 256000, - cpuUsage: 30, - ); - - const meshGateway = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:01', - model: 'MR7500', - isMaster: true, - ); - - const meshExtender = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:02', - model: 'MX5500', - isMaster: false, - ); - - const wifiDevice = DeviceUIModel( - mac: '11:22:33:44:55:01', - ip: '192.168.1.100', - hostName: 'iPhone', - isActive: true, - isWifi: true, - signalStrength: -55, - parentNodeId: 'AA:BB:CC:DD:EE:01', - ); - - const ethernetDevice = DeviceUIModel( - mac: '11:22:33:44:55:02', - ip: '192.168.1.101', - hostName: 'Desktop', - isActive: true, - isWifi: false, - parentNodeId: 'AA:BB:CC:DD:EE:01', + cpuUsage: 25, ); - const offlineDevice = DeviceUIModel( - mac: '11:22:33:44:55:03', - ip: '192.168.1.102', - hostName: 'Tablet', - isActive: false, - isWifi: true, - signalStrength: -80, - parentNodeId: 'AA:BB:CC:DD:EE:02', - ); - - // --------------------------------------------------------------------------- - // Non-mesh topology (single router) - // --------------------------------------------------------------------------- - - group('UspTopologyBuilder - non-mesh', () { - test('builds gateway node with synthetic id when no mesh nodes', () { - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [wifiDevice], - nodeModels: [], - ); - - expect(topo.nodes.where((n) => n.type == MeshNodeType.gateway), - hasLength(1)); - final gateway = topo.nodes.first; - expect(gateway.id, 'gateway'); - expect(gateway.name, 'MR7500'); - expect(gateway.metadata?['deviceId'], 'gateway'); - }); - - test('all client nodes link to gateway when no mesh', () { - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [wifiDevice, ethernetDevice], - nodeModels: [], - ); - - final clientLinks = - topo.links.where((l) => l.sourceId == 'gateway').toList(); - expect(clientLinks, hasLength(2)); - }); - - test('no extender nodes when mesh is empty', () { - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [wifiDevice], - nodeModels: [], - ); - - final extenders = - topo.nodes.where((n) => n.type == MeshNodeType.extender); - expect(extenders, isEmpty); - }); - }); - - // --------------------------------------------------------------------------- - // Mesh topology (gateway + extenders) - // --------------------------------------------------------------------------- - - group('UspTopologyBuilder - mesh', () { - test('builds gateway with real deviceId from first mesh node', () { - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [], - nodeModels: [meshGateway, meshExtender], - ); - - final gateway = - topo.nodes.firstWhere((n) => n.type == MeshNodeType.gateway); - expect(gateway.metadata?['deviceId'], 'AA:BB:CC:DD:EE:01'); - }); - - test('builds extender nodes from mesh nodes (skip first)', () { - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [], - nodeModels: [meshGateway, meshExtender], - ); - - final extenders = - topo.nodes.where((n) => n.type == MeshNodeType.extender).toList(); - expect(extenders, hasLength(1)); - expect(extenders.first.name, 'MX5500'); - expect(extenders.first.metadata?['deviceId'], 'AA:BB:CC:DD:EE:02'); - }); - - test('extender links to gateway', () { - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [], - nodeModels: [meshGateway, meshExtender], - ); - - final extenderLink = - topo.links.where((l) => l.targetId.startsWith('extender-')).toList(); - expect(extenderLink, hasLength(1)); - expect(extenderLink.first.sourceId, 'gateway'); - expect(extenderLink.first.connectionType, ConnectionType.wifi); - }); - - test('client node links to correct extender based on parentNodeId', () { - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [offlineDevice], - nodeModels: [meshGateway, meshExtender], - ); - - final clientLink = topo.links - .where((l) => l.targetId == 'client-${offlineDevice.mac}') - .first; - expect(clientLink.sourceId, 'extender-AA:BB:CC:DD:EE:02'); - }); - - test('client node links to gateway when parentNodeId is not an extender', - () { - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [wifiDevice], - nodeModels: [meshGateway, meshExtender], - ); - - final clientLink = topo.links - .where((l) => l.targetId == 'client-${wifiDevice.mac}') - .first; - // wifiDevice's parentNodeId is AA:BB:CC:DD:EE:01 which is the gateway, - // not in extenderNodeIds set, so falls back to gateway - expect(clientLink.sourceId, 'gateway'); - }); - - test('client links to extender when parentNodeId matches dataElementsId', - () { - // Slave's Hosts MAC differs from its DataElements ID — clientToNodeMap - // uses the DataElements ID (no colons). - const slaveWithDifferentDeId = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:02', // Hosts MAC - dataElementsId: '11:11:11:22:22:22', // DataElements node id - model: 'MX5500', - isMaster: false, - ); - const clientWithDeParent = DeviceUIModel( - mac: '99:88:77:66:55:44', - ip: '192.168.1.150', - hostName: 'LivingRoomTV', - isActive: true, - isWifi: true, - signalStrength: -60, - // parentNodeId from clientToNodeMap is normalized (no colons, upper) - parentNodeId: '111111222222', - ); - - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [clientWithDeParent], - nodeModels: [meshGateway, slaveWithDifferentDeId], - ); - - final clientLink = topo.links - .where((l) => l.targetId == 'client-${clientWithDeParent.mac}') - .first; - // Should attach to the extender (built from Hosts deviceId), not gateway - expect(clientLink.sourceId, 'extender-AA:BB:CC:DD:EE:02'); - }); - - test( - 'client links to extender when parentNodeId matches Hosts MAC ' - '(normalized)', () { - // parentNodeId in normalized form (no colons) should still match - // against the slave's Hosts deviceId. - const clientWithNormalizedParent = DeviceUIModel( - mac: '99:88:77:66:55:55', - ip: '192.168.1.151', - hostName: 'Phone', - isActive: true, - isWifi: true, - signalStrength: -60, - parentNodeId: 'AABBCCDDEE02', // no colons - ); - - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [clientWithNormalizedParent], - nodeModels: [meshGateway, meshExtender], - ); - - final clientLink = topo.links - .where( - (l) => l.targetId == 'client-${clientWithNormalizedParent.mac}') - .first; - expect(clientLink.sourceId, 'extender-AA:BB:CC:DD:EE:02'); - }); - }); - - // --------------------------------------------------------------------------- - // Client node properties - // --------------------------------------------------------------------------- - - group('UspTopologyBuilder - client nodes', () { - test('wifi client has wifi connection type', () { - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [wifiDevice], - nodeModels: [], - ); - - final link = - topo.links.where((l) => l.targetId.startsWith('client-')).first; - expect(link.connectionType, ConnectionType.wifi); - expect(link.rssi, -55); - }); - - test('ethernet client has ethernet connection type', () { - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [ethernetDevice], - nodeModels: [], - ); - - final link = - topo.links.where((l) => l.targetId.startsWith('client-')).first; - expect(link.connectionType, ConnectionType.ethernet); - expect(link.rssi, isNull); - }); - - test('online client has online status', () { - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [wifiDevice], - nodeModels: [], - ); - - final client = - topo.nodes.firstWhere((n) => n.type == MeshNodeType.client); - expect(client.status, MeshNodeStatus.online); - }); - - test('offline client has offline status', () { - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [offlineDevice], - nodeModels: [], - ); - - final client = - topo.nodes.firstWhere((n) => n.type == MeshNodeType.client); - expect(client.status, MeshNodeStatus.offline); - }); - - test('client name uses displayName (hostName if available)', () { - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [wifiDevice], - nodeModels: [], - ); - - final client = - topo.nodes.firstWhere((n) => n.type == MeshNodeType.client); - expect(client.name, 'iPhone'); - }); - }); - - // --------------------------------------------------------------------------- - // Signal quality and level mapping - // --------------------------------------------------------------------------- - - group('UspTopologyBuilder - signal mapping', () { - test('strong wifi signal maps to high level', () { - const device = DeviceUIModel( - mac: 'AA:AA:AA:AA:AA:AA', - ip: '192.168.1.1', - hostName: 'Strong', - isActive: true, - isWifi: true, - signalStrength: -45, - ); - - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [device], - nodeModels: [], - ); - - final client = - topo.nodes.firstWhere((n) => n.type == MeshNodeType.client); - expect(client.level, 0.9); - expect(client.linkQuality, LinkQuality.excellent); - }); - - test('medium wifi signal maps to medium level', () { - // wifi.dart thresholds: [-65, -71, -78] - // -75 is >= -78 (fair) → level 0.4, LinkQuality.good - const device = DeviceUIModel( - mac: 'AA:AA:AA:AA:AA:AA', - ip: '192.168.1.1', - hostName: 'Medium', - isActive: true, - isWifi: true, - signalStrength: -75, - ); - - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [device], - nodeModels: [], - ); - - final client = - topo.nodes.firstWhere((n) => n.type == MeshNodeType.client); - expect(client.level, 0.4); - expect(client.linkQuality, LinkQuality.good); - }); - - test('good wifi signal (-68) maps to level 0.65', () { - // wifi.dart thresholds: [-65, -71, -78] - // -68 is in (-71, -65] → good → level 0.65, LinkQuality.excellent - const device = DeviceUIModel( - mac: 'AA:AA:AA:AA:AA:AB', - ip: '192.168.1.1', - hostName: 'Good', - isActive: true, - isWifi: true, - signalStrength: -68, - ); - - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [device], - nodeModels: [], - ); - - final client = - topo.nodes.firstWhere((n) => n.type == MeshNodeType.client); - expect(client.level, 0.65); - expect(client.linkQuality, LinkQuality.excellent); - }); - - test('weak wifi signal maps to low level', () { - // wifi.dart thresholds: [-65, -71, -78] - // -80 is < -78 (poor) → LinkQuality.fair - const device = DeviceUIModel( - mac: 'AA:AA:AA:AA:AA:AA', - ip: '192.168.1.1', - hostName: 'Weak', - isActive: true, - isWifi: true, - signalStrength: -80, - ); - - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [device], - nodeModels: [], - ); - - final client = - topo.nodes.firstWhere((n) => n.type == MeshNodeType.client); - expect(client.level, 0.1); - expect(client.linkQuality, LinkQuality.fair); - }); - - test('ethernet device maps to wired signal quality and level 1.0', () { - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [ethernetDevice], - nodeModels: [], - ); - - final client = - topo.nodes.firstWhere((n) => n.type == MeshNodeType.client); - expect(client.level, 1.0); - expect(client.linkQuality, LinkQuality.stable); - }); - - test('wifi device with null RSSI maps to unknown quality', () { - const device = DeviceUIModel( - mac: 'AA:AA:AA:AA:AA:AA', - ip: '192.168.1.1', - hostName: 'NoRSSI', - isActive: true, - isWifi: true, - signalStrength: null, - ); - - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [device], - nodeModels: [], - ); - - final client = - topo.nodes.firstWhere((n) => n.type == MeshNodeType.client); - expect(client.level, 0.0); - expect(client.linkQuality, LinkQuality.unknown); - }); - }); - - // --------------------------------------------------------------------------- - // Total node/link counts - // --------------------------------------------------------------------------- - - group('UspTopologyBuilder - totals', () { - test('correct total nodes and links for mesh + clients', () { - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [wifiDevice, ethernetDevice, offlineDevice], - nodeModels: [meshGateway, meshExtender], - ); - - // 1 gateway + 1 extender + 3 clients = 5 nodes - expect(topo.nodes, hasLength(5)); - // 1 gateway→extender link + 3 client links = 4 links - expect(topo.links, hasLength(4)); - }); - - test('empty devices produces only infrastructure nodes', () { - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [], - nodeModels: [meshGateway, meshExtender], - ); - - // 1 gateway + 1 extender = 2 nodes - expect(topo.nodes, hasLength(2)); - // 1 gateway→extender link - expect(topo.links, hasLength(1)); - }); - }); - - // --------------------------------------------------------------------------- - // Gateway metadata (enriched in topology enhancements) - // --------------------------------------------------------------------------- - - group('UspTopologyBuilder - gateway metadata', () { - test('gateway metadata includes all system info fields', () { - const meshGatewayWithFullInfo = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:01', - model: 'MR7500', - manufacturer: 'Linksys', - serialNumber: 'SN123', - softwareVersion: '2.0.0', - isMaster: true, - ); - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [], - nodeModels: [meshGatewayWithFullInfo], - ); - - final gateway = - topo.nodes.firstWhere((n) => n.type == MeshNodeType.gateway); - expect(gateway.metadata?['model'], 'MR7500'); - expect(gateway.metadata?['manufacturer'], 'Linksys'); - expect(gateway.metadata?['serialNumber'], 'SN123'); - expect(gateway.metadata?['softwareVersion'], '2.0.0'); - expect(gateway.metadata?['isMaster'], isTrue); - }); - - test('gateway metadata deviceId uses mesh node deviceId when available', - () { - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [], - nodeModels: [meshGateway], - ); - - final gateway = - topo.nodes.firstWhere((n) => n.type == MeshNodeType.gateway); - expect(gateway.metadata?['deviceId'], 'AA:BB:CC:DD:EE:01'); - }); - - test('gateway metadata deviceId is "gateway" when no mesh nodes', () { - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [], - nodeModels: [], - ); - - final gateway = - topo.nodes.firstWhere((n) => n.type == MeshNodeType.gateway); - expect(gateway.metadata?['deviceId'], 'gateway'); - }); - }); - - // --------------------------------------------------------------------------- - // Extender metadata - // --------------------------------------------------------------------------- - - group('UspTopologyBuilder - extender metadata', () { - const extenderWithFullInfo = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:02', - model: 'MX5500', - manufacturer: 'Linksys', - serialNumber: 'SN456', - softwareVersion: '1.5.0', - isMaster: false, - ); - - test('extender metadata includes all mesh node fields', () { - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [], - nodeModels: [meshGateway, extenderWithFullInfo], - ); - - final extender = - topo.nodes.firstWhere((n) => n.type == MeshNodeType.extender); - expect(extender.metadata?['deviceId'], 'AA:BB:CC:DD:EE:02'); - expect(extender.metadata?['model'], 'MX5500'); - expect(extender.metadata?['manufacturer'], 'Linksys'); - expect(extender.metadata?['serialNumber'], 'SN456'); - expect(extender.metadata?['softwareVersion'], '1.5.0'); - expect(extender.metadata?['isMaster'], isFalse); - }); - - test('extender metadata includes backhaul fields', () { - const extenderWithBackhaul = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:02', - model: 'MX5500', - isMaster: false, - backhaulLinkType: 'Wi-Fi', - backhaulParentDeviceId: 'AA:BB:CC:DD:EE:01', - backhaulSignalStrength: -45, - backhaulUplinkRate: 500000, - backhaulDownlinkRate: 600000, - lastContactTime: '2026-06-01T10:00:00Z', - ); - - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [], - nodeModels: [meshGateway, extenderWithBackhaul], - ); - - final extender = - topo.nodes.firstWhere((n) => n.type == MeshNodeType.extender); - expect(extender.metadata?['backhaulLinkType'], 'Wi-Fi'); - expect(extender.metadata?['backhaulParentDeviceId'], 'AA:BB:CC:DD:EE:01'); - expect(extender.metadata?['backhaulSignalStrength'], -45); - expect(extender.metadata?['backhaulUplinkRate'], 500000); - expect(extender.metadata?['backhaulDownlinkRate'], 600000); - expect(extender.metadata?['lastContactTime'], '2026-06-01T10:00:00Z'); - }); - }); - - // --------------------------------------------------------------------------- - // Multi-layer mesh (Slave → Slave → Master) - // --------------------------------------------------------------------------- - - group('UspTopologyBuilder - multi-layer mesh', () { - test( - 'slave links to another slave when backhaulParentDeviceId points to slave', - () { - const slaveA = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:02', - model: 'MX5500', - isMaster: false, - backhaulParentDeviceId: 'AA:BB:CC:DD:EE:01', // Points to master - ); - const slaveB = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:03', - model: 'MX5500', - isMaster: false, - backhaulParentDeviceId: 'AA:BB:CC:DD:EE:02', // Points to slaveA - ); - - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [], - nodeModels: [meshGateway, slaveA, slaveB], - ); - - // SlaveA should link to gateway - final slaveALink = topo.links - .firstWhere((l) => l.targetId == 'extender-AA:BB:CC:DD:EE:02'); - expect(slaveALink.sourceId, 'gateway'); - - // SlaveB should link to slaveA - final slaveBLink = topo.links - .firstWhere((l) => l.targetId == 'extender-AA:BB:CC:DD:EE:03'); - expect(slaveBLink.sourceId, 'extender-AA:BB:CC:DD:EE:02'); - }); - - test('slave links to gateway when backhaulParentDeviceId is null', () { - const slaveWithoutParent = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:02', - model: 'MX5500', - isMaster: false, - backhaulParentDeviceId: null, - ); - - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [], - nodeModels: [meshGateway, slaveWithoutParent], - ); - - final link = topo.links - .firstWhere((l) => l.targetId == 'extender-AA:BB:CC:DD:EE:02'); - expect(link.sourceId, 'gateway'); - }); - - test('slave links to gateway when backhaulParentDeviceId matches master', - () { - const slaveConnectedToMaster = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:02', - model: 'MX5500', - isMaster: false, - backhaulParentDeviceId: 'AA:BB:CC:DD:EE:01', // Points to master - ); - - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [], - nodeModels: [meshGateway, slaveConnectedToMaster], - ); - - final link = topo.links - .firstWhere((l) => l.targetId == 'extender-AA:BB:CC:DD:EE:02'); - expect(link.sourceId, 'gateway'); - }); - - test( - 'slave links via dataElementsId when backhaulParentDeviceId uses DE ID', - () { - const slaveAWithDeId = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:02', - dataElementsId: '11:11:11:22:22:22', - model: 'MX5500', - isMaster: false, - backhaulParentDeviceId: 'AA:BB:CC:DD:EE:01', - ); - const slaveBPointingToDeId = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:03', - model: 'MX5500', - isMaster: false, - backhaulParentDeviceId: '11:11:11:22:22:22', // Points to slaveA's DE ID - ); - - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [], - nodeModels: [meshGateway, slaveAWithDeId, slaveBPointingToDeId], - ); - - final slaveBLink = topo.links - .firstWhere((l) => l.targetId == 'extender-AA:BB:CC:DD:EE:03'); - expect(slaveBLink.sourceId, 'extender-AA:BB:CC:DD:EE:02'); - }); - }); - - // --------------------------------------------------------------------------- - // Backhaul link type - // --------------------------------------------------------------------------- - - group('UspTopologyBuilder - backhaul link type', () { - test('Ethernet backhaul uses ethernet connection type', () { - const ethernetSlave = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:02', - model: 'MX5500', - isMaster: false, - backhaulLinkType: 'Ethernet', - ); - - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [], - nodeModels: [meshGateway, ethernetSlave], - ); - - final link = topo.links - .firstWhere((l) => l.targetId == 'extender-AA:BB:CC:DD:EE:02'); - expect(link.connectionType, ConnectionType.ethernet); - }); - - test('Wi-Fi backhaul uses wifi connection type', () { - const wifiSlave = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:02', - model: 'MX5500', - isMaster: false, - backhaulLinkType: 'Wi-Fi', - ); - - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [], - nodeModels: [meshGateway, wifiSlave], - ); - - final link = topo.links - .firstWhere((l) => l.targetId == 'extender-AA:BB:CC:DD:EE:02'); - expect(link.connectionType, ConnectionType.wifi); - }); - - test('null backhaul link type defaults to wifi connection type', () { - const slavWithoutLinkType = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:02', - model: 'MX5500', - isMaster: false, - backhaulLinkType: null, - ); - - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [], - nodeModels: [meshGateway, slavWithoutLinkType], - ); - - final link = topo.links - .firstWhere((l) => l.targetId == 'extender-AA:BB:CC:DD:EE:02'); - expect(link.connectionType, ConnectionType.wifi); - }); - }); - - // --------------------------------------------------------------------------- - // Client node icons (DeviceClassifier integration) - // --------------------------------------------------------------------------- - - group('UspTopologyBuilder - client icons', () { - test('iPhone hostname gets phone icon', () { - const device = DeviceUIModel( - mac: '11:22:33:44:55:01', - ip: '192.168.1.100', - hostName: 'iPhone', - isActive: true, - isWifi: true, - ); - - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [device], - nodeModels: [], - ); - - final client = - topo.nodes.firstWhere((n) => n.type == MeshNodeType.client); - expect(client.iconData, DeviceCategory.phone.icon); - }); - - test('MacBook hostname gets computer icon', () { - const device = DeviceUIModel( - mac: '11:22:33:44:55:02', - ip: '192.168.1.101', - hostName: 'MacBook-Pro', - isActive: true, - isWifi: true, - ); - - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [device], - nodeModels: [], - ); - - final client = - topo.nodes.firstWhere((n) => n.type == MeshNodeType.client); - expect(client.iconData, DeviceCategory.computer.icon); - }); - - test('PlayStation hostname gets game console icon', () { - const device = DeviceUIModel( - mac: '11:22:33:44:55:03', - ip: '192.168.1.102', - hostName: 'PlayStation5', - isActive: true, - isWifi: false, - ); - - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [device], - nodeModels: [], - ); - - final client = - topo.nodes.firstWhere((n) => n.type == MeshNodeType.client); - expect(client.iconData, DeviceCategory.gameConsole.icon); - }); - - test('unknown hostname with unknown OUI gets unknown icon', () { - // Use universally administered MAC (bit 1 of first byte = 0) - // that's not in our test OUI database - const device = DeviceUIModel( - mac: '00:FF:FF:44:55:04', - ip: '192.168.1.103', - hostName: 'device-12345', - isActive: true, - isWifi: true, - ); - - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [device], - nodeModels: [], - ); - - final client = - topo.nodes.firstWhere((n) => n.type == MeshNodeType.client); - expect(client.iconData, DeviceCategory.unknown.icon); - }); - - test('client metadata includes mac address', () { - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [wifiDevice], - nodeModels: [], - ); - - final client = - topo.nodes.firstWhere((n) => n.type == MeshNodeType.client); - expect(client.metadata?['mac'], '11:22:33:44:55:01'); + group('UspTopologyBuilder.buildFromMeshNetwork', () { + // ========================================================================= + // Basic Topology Structure + // ========================================================================= + + group('basic structure', () { + test('creates gateway node for single-node network', () { + final meshNetwork = DevicesTestData.createSingleNodeNetwork(); + + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); + + expect(topology.nodes, isNotEmpty); + final gateway = + topology.nodes.where((n) => n.type == MeshNodeType.gateway).first; + expect(gateway.id, 'gateway'); + expect(gateway.status, MeshNodeStatus.online); + }); + + test('creates extender nodes for mesh network', () { + final meshNetwork = DevicesTestData.createMeshNetwork(); + + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); + + final extenders = + topology.nodes.where((n) => n.type == MeshNodeType.extender); + expect(extenders, hasLength(1)); + expect(extenders.first.id, startsWith('extender-')); + }); + + test('creates client nodes for connected devices', () { + final meshNetwork = DevicesTestData.createSingleNodeNetwork(); + + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); + + final clients = + topology.nodes.where((n) => n.type == MeshNodeType.client); + expect(clients, hasLength(2)); // WiFi + Wired from test data + }); + + test('creates links between nodes', () { + final meshNetwork = DevicesTestData.createMeshNetwork(); + + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); + + expect(topology.links, isNotEmpty); + // Should have link from gateway to extender (sourceId=parent, targetId=child) + final extenderLink = topology.links + .where((l) => l.targetId.startsWith('extender-')) + .firstOrNull; + expect(extenderLink, isNotNull); + expect(extenderLink?.sourceId, 'gateway'); + }); + }); + + // ========================================================================= + // Gateway Node Properties + // ========================================================================= + + group('gateway node', () { + test('uses master displayName when available', () { + final master = DevicesTestData.createMaster( + friendlyName: 'My Router', + ); + final meshNetwork = DevicesTestData.createSingleNodeNetwork( + master: master, + ); + + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); + + final gateway = + topology.nodes.where((n) => n.type == MeshNodeType.gateway).first; + expect(gateway.name, 'My Router'); + }); + + test('falls back to systemInfo gatewayName', () { + final master = DevicesTestData.createMaster( + friendlyName: null, + hostName: null, + ); + final meshNetwork = DevicesTestData.createSingleNodeNetwork( + master: master.copyWith(connectedClients: []), + ); + + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); + + final gateway = + topology.nodes.where((n) => n.type == MeshNodeType.gateway).first; + // Falls back to model when displayName empty, or gatewayName from sysInfo + expect(gateway.name, isNotEmpty); + }); + + test('includes metadata with deviceId and model', () { + final meshNetwork = DevicesTestData.createSingleNodeNetwork(); + + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); + + final gateway = + topology.nodes.where((n) => n.type == MeshNodeType.gateway).first; + expect(gateway.metadata?['deviceId'], isNotNull); + expect(gateway.metadata?['isMaster'], isTrue); + }); + + test('has level 1.0', () { + final meshNetwork = DevicesTestData.createSingleNodeNetwork(); + + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); + + final gateway = + topology.nodes.where((n) => n.type == MeshNodeType.gateway).first; + expect(gateway.level, 1.0); + }); + }); + + // ========================================================================= + // Extender Node Properties + // ========================================================================= + + group('extender nodes', () { + test('uses slave displayName', () { + final slave = DevicesTestData.createWifiSlave( + friendlyName: 'Living Room Extender', + ); + final meshNetwork = MeshNetwork( + master: DevicesTestData.createMaster(), + slaves: [slave], + ); + + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); + + final extender = + topology.nodes.where((n) => n.type == MeshNodeType.extender).first; + expect(extender.name, 'Living Room Extender'); + }); + + test('includes backhaul metadata', () { + final meshNetwork = DevicesTestData.createMeshNetwork(); + + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); + + final extender = + topology.nodes.where((n) => n.type == MeshNodeType.extender).first; + expect(extender.metadata?['backhaulLinkType'], isNotNull); + expect(extender.metadata?['isMaster'], isFalse); + }); + + test('WiFi backhaul has level based on signal strength', () { + final slave = DevicesTestData.createWifiSlave( + backhaul: DevicesTestData.createWifiBackhaul(signalStrength: -50), + ); + final meshNetwork = MeshNetwork( + master: DevicesTestData.createMaster(), + slaves: [slave], + ); + + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); + + final extender = + topology.nodes.where((n) => n.type == MeshNodeType.extender).first; + // Excellent signal (-50) should have high level (0.9) + expect(extender.level, 0.9); + }); + + test('Ethernet backhaul has default level', () { + final slave = DevicesTestData.createEthernetSlave(); + final meshNetwork = MeshNetwork( + master: DevicesTestData.createMaster(), + slaves: [slave], + ); + + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); + + final extender = + topology.nodes.where((n) => n.type == MeshNodeType.extender).first; + // No signal → 0.5 default + expect(extender.level, 0.5); + }); + + test('parentId defaults to gateway', () { + final meshNetwork = DevicesTestData.createMeshNetwork(); + + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); + + final extender = + topology.nodes.where((n) => n.type == MeshNodeType.extender).first; + expect(extender.parentId, 'gateway'); + }); + }); + + // ========================================================================= + // Client Node Properties + // ========================================================================= + + group('client nodes', () { + test('creates client node with correct id format', () { + final meshNetwork = DevicesTestData.createSingleNodeNetwork(); + + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); + + final clients = + topology.nodes.where((n) => n.type == MeshNodeType.client); + for (final client in clients) { + expect(client.id, startsWith('client-')); + } + }); + + test('WiFi client has level based on signal strength', () { + final wifiClient = DevicesTestData.createWifiClient( + wifi: DevicesTestData.createExcellentSignal(), + ); + final meshNetwork = DevicesTestData.createSingleNodeNetwork( + masterClients: [wifiClient], + ); + + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); + + final client = + topology.nodes.where((n) => n.type == MeshNodeType.client).first; + // Excellent signal should have high level (0.9) + expect(client.level, 0.9); + }); + + test('wired client has level 1.0', () { + final wiredClient = DevicesTestData.createWiredClient(); + final meshNetwork = DevicesTestData.createSingleNodeNetwork( + masterClients: [wiredClient], + ); + + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); + + final client = + topology.nodes.where((n) => n.type == MeshNodeType.client).first; + expect(client.level, 1.0); + }); + + test('offline client has offline status', () { + final offlineClient = DevicesTestData.createOfflineClient(); + final meshNetwork = DevicesTestData.createSingleNodeNetwork( + masterClients: [offlineClient], + ); + + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); + + final client = + topology.nodes.where((n) => n.type == MeshNodeType.client).first; + expect(client.status, MeshNodeStatus.offline); + }); + + test('client parentId points to correct node', () { + final slaveClient = DevicesTestData.createSlaveConnectedClient( + parentNodeId: DevicesTestData.slaveMac1, + ); + final meshNetwork = DevicesTestData.createMeshNetwork( + slaveClients: [slaveClient], + ); + + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); + + final client = topology.nodes + .where((n) => + n.type == MeshNodeType.client && + n.metadata?['mac'] == DevicesTestData.clientMac5) + .firstOrNull; + expect(client, isNotNull); + expect(client?.parentId, startsWith('extender-')); + }); + + test('includes MAC in metadata', () { + final meshNetwork = DevicesTestData.createSingleNodeNetwork(); + + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); + + final client = + topology.nodes.where((n) => n.type == MeshNodeType.client).first; + expect(client.metadata?['mac'], isNotNull); + }); + }); + + // ========================================================================= + // Link Properties + // ========================================================================= + + group('links', () { + test('creates link from extender to gateway', () { + final meshNetwork = DevicesTestData.createMeshNetwork(); + + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); + + // Link direction: sourceId=parent, targetId=child + // extender → gateway means link with sourceId='gateway', targetId='extender-*' + final extenderLinks = topology.links + .where((l) => l.targetId.startsWith('extender-')) + .toList(); + expect(extenderLinks, isNotEmpty); + expect(extenderLinks.first.sourceId, 'gateway'); + }); + + test('creates links from clients to parent nodes', () { + final meshNetwork = DevicesTestData.createSingleNodeNetwork(); + + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); + + // Link direction: sourceId=parent, targetId=child + final clientLinks = topology.links + .where((l) => l.targetId.startsWith('client-')) + .toList(); + expect(clientLinks, hasLength(2)); // 2 clients in test data + for (final link in clientLinks) { + expect(link.sourceId, 'gateway'); + } + }); + + test('WiFi link has quality based on signal', () { + final wifiClient = DevicesTestData.createWifiClient( + wifi: DevicesTestData.createExcellentSignal(), + ); + final meshNetwork = DevicesTestData.createSingleNodeNetwork( + masterClients: [wifiClient], + ); + + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); + + // Link direction: sourceId=parent, targetId=child (client) + final link = + topology.links.where((l) => l.targetId.startsWith('client-')).first; + expect(link.linkQuality, LinkQuality.excellent); + }); + + test('wired link has stable quality', () { + final wiredClient = DevicesTestData.createWiredClient(); + final meshNetwork = DevicesTestData.createSingleNodeNetwork( + masterClients: [wiredClient], + ); + + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); + + // Link direction: sourceId=parent, targetId=child (client) + final link = + topology.links.where((l) => l.targetId.startsWith('client-')).first; + expect(link.linkQuality, LinkQuality.stable); + }); + }); + + // ========================================================================= + // Multi-Slave Network + // ========================================================================= + + group('multi-slave network', () { + test('creates all extender nodes', () { + final meshNetwork = DevicesTestData.createMultiSlaveMeshNetwork(); + + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); + + final extenders = + topology.nodes.where((n) => n.type == MeshNodeType.extender); + expect(extenders, hasLength(2)); + }); + + test('clients connect to correct parent nodes', () { + final meshNetwork = DevicesTestData.createMultiSlaveMeshNetwork(); + + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); + + // Master client should connect to gateway + final masterClients = topology.nodes + .where( + (n) => n.type == MeshNodeType.client && n.parentId == 'gateway') + .toList(); + expect(masterClients, isNotEmpty); + + // Slave clients should connect to extenders + final slaveClients = topology.nodes + .where((n) => + n.type == MeshNodeType.client && + n.parentId != null && + n.parentId!.startsWith('extender-')) + .toList(); + expect(slaveClients, hasLength(2)); // One per slave + }); + }); + + // ========================================================================= + // Edge Cases + // ========================================================================= + + group('edge cases', () { + test('handles empty network (no clients)', () { + final meshNetwork = DevicesTestData.createEmptyNetwork(); + + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); + + expect(topology.nodes, hasLength(1)); // Gateway only + final clients = + topology.nodes.where((n) => n.type == MeshNodeType.client); + expect(clients, isEmpty); + }); + + test('handles network with unassigned clients', () { + final meshNetwork = + DevicesTestData.createNetworkWithUnassignedClients(); + + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); + + final clients = + topology.nodes.where((n) => n.type == MeshNodeType.client); + expect(clients, hasLength(2)); + // Unassigned clients should connect to gateway + for (final client in clients) { + expect(client.parentId, 'gateway'); + } + }); + + test('handles client with poor signal', () { + final poorSignalClient = DevicesTestData.createWifiClient( + wifi: DevicesTestData.createPoorSignal(), + ); + final meshNetwork = DevicesTestData.createSingleNodeNetwork( + masterClients: [poorSignalClient], + ); + + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); + + final client = + topology.nodes.where((n) => n.type == MeshNodeType.client).first; + // Poor signal (-85) should have low level (0.1) + expect(client.level, 0.1); + + // Link direction: sourceId=parent, targetId=child (client) + final link = + topology.links.where((l) => l.targetId.startsWith('client-')).first; + // Poor signal maps to unknown quality + expect(link.linkQuality, LinkQuality.unknown); + }); }); }); } diff --git a/test/page/topology/models/node_ui_model_test.dart b/test/page/topology/models/node_ui_model_test.dart deleted file mode 100644 index 382c7c695..000000000 --- a/test/page/topology/models/node_ui_model_test.dart +++ /dev/null @@ -1,311 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:privacy_gui/page/topology/models/node_ui_model.dart'; - -void main() { - // --------------------------------------------------------------------------- - // NodeUIModel — displayName priority - // --------------------------------------------------------------------------- - - group('NodeUIModel — displayName', () { - test('displayName prefers friendlyName when available', () { - const node = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:01', - friendlyName: 'Living Room Router', - hostName: 'linksys-router', - model: 'MR7500', - isMaster: true, - ); - expect(node.displayName, 'Living Room Router'); - }); - - test('displayName uses hostName when friendlyName is null', () { - const node = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:01', - friendlyName: null, - hostName: 'linksys-router', - model: 'MR7500', - isMaster: true, - ); - expect(node.displayName, 'linksys-router'); - }); - - test('displayName uses hostName when friendlyName is empty', () { - const node = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:01', - friendlyName: '', - hostName: 'linksys-router', - model: 'MR7500', - isMaster: true, - ); - expect(node.displayName, 'linksys-router'); - }); - - test('displayName uses model when friendlyName and hostName are empty', () { - const node = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:01', - friendlyName: '', - hostName: '', - model: 'MR7500', - isMaster: true, - ); - expect(node.displayName, 'MR7500'); - }); - - test('displayName uses model when friendlyName and hostName are null', () { - const node = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:01', - friendlyName: null, - hostName: null, - model: 'MR7500', - isMaster: true, - ); - expect(node.displayName, 'MR7500'); - }); - - test('displayName falls back to deviceId when all names are empty', () { - const node = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:01', - friendlyName: '', - hostName: '', - model: '', - isMaster: true, - ); - expect(node.displayName, 'AA:BB:CC:DD:EE:01'); - }); - }); - - // --------------------------------------------------------------------------- - // NodeUIModel — roleLabel - // --------------------------------------------------------------------------- - - group('NodeUIModel — roleLabel', () { - test('roleLabel is Master for isMaster=true', () { - const node = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:01', - model: 'MR7500', - isMaster: true, - ); - expect(node.roleLabel, 'Master'); - }); - - test('roleLabel is Slave for isMaster=false', () { - const node = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:02', - model: 'MX5500', - isMaster: false, - ); - expect(node.roleLabel, 'Slave'); - }); - }); - - // --------------------------------------------------------------------------- - // NodeUIModel — hasBackhaul - // --------------------------------------------------------------------------- - - group('NodeUIModel — hasBackhaul', () { - test('hasBackhaul is true when backhaulMediaType is set', () { - const node = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:02', - model: 'MX5500', - isMaster: false, - backhaulMediaType: 'IEEE 802.11ax', - backhaulPhyRate: 1200, - ); - expect(node.hasBackhaul, isTrue); - }); - - test('hasBackhaul is false when backhaulMediaType is empty', () { - const node = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:01', - model: 'MR7500', - isMaster: true, - ); - expect(node.hasBackhaul, isFalse); - }); - }); - - // --------------------------------------------------------------------------- - // NodeUIModel — Equatable - // --------------------------------------------------------------------------- - - group('NodeUIModel — Equatable', () { - test('equality based on all fields', () { - const node1 = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:01', - friendlyName: 'Router', - hostName: 'linksys', - model: 'MR7500', - manufacturer: 'Linksys', - serialNumber: 'SN123', - softwareVersion: '2.0.0', - isMaster: true, - connectedDeviceCount: 5, - backhaulMediaType: '', - backhaulPhyRate: 0, - ); - const node2 = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:01', - friendlyName: 'Router', - hostName: 'linksys', - model: 'MR7500', - manufacturer: 'Linksys', - serialNumber: 'SN123', - softwareVersion: '2.0.0', - isMaster: true, - connectedDeviceCount: 5, - backhaulMediaType: '', - backhaulPhyRate: 0, - ); - const node3 = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:02', // Different deviceId - friendlyName: 'Router', - hostName: 'linksys', - model: 'MR7500', - manufacturer: 'Linksys', - serialNumber: 'SN123', - softwareVersion: '2.0.0', - isMaster: true, - connectedDeviceCount: 5, - ); - - expect(node1, equals(node2)); - expect(node1, isNot(equals(node3))); - }); - - test('props includes all fields', () { - const node = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:01', - friendlyName: 'Router', - hostName: 'linksys', - model: 'MR7500', - manufacturer: 'Linksys', - serialNumber: 'SN123', - softwareVersion: '2.0.0', - isMaster: true, - connectedDeviceCount: 5, - ipAddress: '192.168.1.1', - ipv6Addresses: ['fe80::1'], - wanIpAddress: '203.0.113.1', - backhaulMediaType: 'Ethernet', - backhaulPhyRate: 1000, - backhaulSignalStrength: -45, - backhaulUplinkRate: 500000, - backhaulLinkType: 'Wi-Fi', - backhaulDownlinkRate: 600000, - ); - - // 25 props total: base fields + network addresses + DataElements enrichment - expect(node.props, hasLength(25)); - expect(node.props, contains('AA:BB:CC:DD:EE:01')); - expect(node.props, contains('Router')); - expect(node.props, contains('linksys')); - expect(node.props, contains('MR7500')); - expect(node.props, contains('Linksys')); - expect(node.props, contains('SN123')); - expect(node.props, contains('2.0.0')); - expect(node.props, contains(true)); - expect(node.props, contains(5)); - expect(node.props, contains('192.168.1.1')); - // List uses reference equality, so check by finding the list element - expect(node.props.any((p) => p is List && p.contains('fe80::1')), isTrue); - expect(node.props, contains('203.0.113.1')); - expect(node.props, contains('Ethernet')); - expect(node.props, contains(1000)); - expect(node.props, contains(-45)); - expect(node.props, contains(500000)); - expect(node.props, contains('Wi-Fi')); - expect(node.props, contains(600000)); - // Remaining DataElements enrichment fields default to null: - // dataElementsId(1) + instancePath(1) + backhaulAlId(1) + - // backhaulMacAddress(1) + backhaulParentDeviceId(1) + backhaulParentBssid(1) + - // lastContactTime(1) = 7 nulls - expect(node.props.where((p) => p == null).length, 7); - }); - }); - - // --------------------------------------------------------------------------- - // NodeUIModel — default values - // --------------------------------------------------------------------------- - - group('NodeUIModel — default values', () { - test('default values are applied correctly', () { - const node = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:01', - model: 'MR7500', - ); - - expect(node.friendlyName, isNull); - expect(node.hostName, isNull); - expect(node.manufacturer, ''); - expect(node.serialNumber, ''); - expect(node.softwareVersion, ''); - expect(node.isMaster, isFalse); - expect(node.connectedDeviceCount, 0); - expect(node.backhaulMediaType, ''); - expect(node.backhaulPhyRate, 0); - expect(node.backhaulSignalStrength, isNull); - expect(node.backhaulUplinkRate, isNull); - // DataElements enrichment fields default to null - expect(node.instancePath, isNull); - expect(node.backhaulAlId, isNull); - expect(node.backhaulMacAddress, isNull); - }); - }); - - // --------------------------------------------------------------------------- - // NodeUIModelListExt — extension methods - // --------------------------------------------------------------------------- - - group('NodeUIModelListExt', () { - const masterNode = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:01', - model: 'MR7500', - isMaster: true, - ); - const slaveNode1 = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:02', - model: 'MX5500', - isMaster: false, - ); - const slaveNode2 = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:03', - model: 'MX5500', - isMaster: false, - ); - - test('master returns the master node', () { - final nodes = [masterNode, slaveNode1, slaveNode2]; - expect(nodes.master, equals(masterNode)); - }); - - test('master returns null when no master exists', () { - final nodes = [slaveNode1, slaveNode2]; - expect(nodes.master, isNull); - }); - - test('slaves returns all non-master nodes', () { - final nodes = [masterNode, slaveNode1, slaveNode2]; - expect(nodes.slaves, equals([slaveNode1, slaveNode2])); - }); - - test('slaves returns empty list when all nodes are master', () { - final nodes = [masterNode]; - expect(nodes.slaves, isEmpty); - }); - - test('hasMesh returns true when slaves exist', () { - final nodes = [masterNode, slaveNode1]; - expect(nodes.hasMesh, isTrue); - }); - - test('hasMesh returns false when no slaves', () { - final nodes = [masterNode]; - expect(nodes.hasMesh, isFalse); - }); - - test('hasMesh returns false for empty list', () { - final List nodes = []; - expect(nodes.hasMesh, isFalse); - }); - }); -} diff --git a/test/page/topology/providers/node_detail_provider_test.dart b/test/page/topology/providers/node_detail_provider_test.dart index 7d9b98c95..31451a662 100644 --- a/test/page/topology/providers/node_detail_provider_test.dart +++ b/test/page/topology/providers/node_detail_provider_test.dart @@ -1,9 +1,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; +import 'package:privacy_gui/page/_shared/models/backhaul_info.dart'; +import 'package:privacy_gui/page/_shared/models/client_device.dart'; +import 'package:privacy_gui/page/_shared/models/mesh_network.dart'; import 'package:privacy_gui/page/_shared/models/mesh_topology_info.dart'; +import 'package:privacy_gui/page/_shared/models/node_entity.dart'; import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; -import 'package:privacy_gui/page/topology/models/node_ui_model.dart'; import 'package:privacy_gui/page/topology/providers/node_detail_provider.dart'; void main() { @@ -11,58 +13,64 @@ void main() { // Shared test data // --------------------------------------------------------------------------- - const masterNode = NodeUIModel( + final masterNode = MasterNode( deviceId: 'AA:BB:CC:DD:EE:01', model: 'MR7500', - isMaster: true, - connectedDeviceCount: 2, + connectedClients: [], ); - const extenderNode = NodeUIModel( + final extenderNode = SlaveNode( deviceId: 'AA:BB:CC:DD:EE:02', model: 'MX5500', - isMaster: false, - connectedDeviceCount: 1, + connectedClients: [], + backhaul: const BackhaulInfo(mediaType: 'Wi-Fi'), ); - const device1 = DeviceUIModel( + final device1 = ClientDevice( mac: '11:22:33:44:55:01', ip: '192.168.1.100', hostName: 'iPhone', isActive: true, - isWifi: true, + connectionType: ConnectionType.wifi, parentNodeId: 'AA:BB:CC:DD:EE:01', ); - const device2 = DeviceUIModel( + final device2 = ClientDevice( mac: '11:22:33:44:55:02', ip: '192.168.1.101', hostName: 'MacBook', isActive: true, - isWifi: false, + connectionType: ConnectionType.wired, parentNodeId: 'AA:BB:CC:DD:EE:01', ); - const device3 = DeviceUIModel( + final device3 = ClientDevice( mac: '11:22:33:44:55:03', ip: '192.168.1.102', hostName: 'iPad', isActive: false, - isWifi: true, + connectionType: ConnectionType.wifi, parentNodeId: 'AA:BB:CC:DD:EE:02', ); - const meshData = DevicesData( - nodeModels: [masterNode, extenderNode], - deviceModels: [device1, device2, device3], - meshTopology: MeshTopologyInfo( - nodes: [ - NodeUIModel(deviceId: 'AA:BB:CC:DD:EE:01', model: 'MR7500'), - NodeUIModel(deviceId: 'AA:BB:CC:DD:EE:02', model: 'MX5500'), - ], - clientToNodeMap: {}, - ), - ); + DevicesData createMeshData() { + return DevicesData( + meshNetwork: MeshNetwork( + master: masterNode.copyWith( + connectedClients: [device1, device2], + ), + slaves: [ + extenderNode.copyWith( + connectedClients: [device3], + ), + ], + ), + meshTopology: const MeshTopologyInfo( + nodes: [], + clientToNodeMap: {}, + ), + ); + } ProviderContainer createContainer({DevicesData? data}) { return ProviderContainer( @@ -78,27 +86,31 @@ void main() { // ----------------------------------------------------------------------- test('returns node and connected devices for master node', () async { + final meshData = createMeshData(); final container = createContainer(data: meshData); await container.read(devicesDataProvider.future); final detail = container.read(uspNodeDetailProvider('AA:BB:CC:DD:EE:01')); - expect(detail.node, masterNode); - expect(detail.connectedDevices, hasLength(2)); - expect(detail.connectedDevices, contains(device1)); - expect(detail.connectedDevices, contains(device2)); + expect(detail.node, isNotNull); + expect(detail.node!.deviceId, 'AA:BB:CC:DD:EE:01'); + expect(detail.connectedClients, hasLength(2)); + expect(detail.connectedClients.any((d) => d.mac == device1.mac), isTrue); + expect(detail.connectedClients.any((d) => d.mac == device2.mac), isTrue); container.dispose(); }); test('returns node and connected devices for extender node', () async { + final meshData = createMeshData(); final container = createContainer(data: meshData); await container.read(devicesDataProvider.future); final detail = container.read(uspNodeDetailProvider('AA:BB:CC:DD:EE:02')); - expect(detail.node, extenderNode); - expect(detail.connectedDevices, hasLength(1)); - expect(detail.connectedDevices.first.hostName, 'iPad'); + expect(detail.node, isNotNull); + expect(detail.node!.deviceId, 'AA:BB:CC:DD:EE:02'); + expect(detail.connectedClients, hasLength(1)); + expect(detail.connectedClients.first.hostName, 'iPad'); container.dispose(); }); @@ -107,13 +119,14 @@ void main() { // ----------------------------------------------------------------------- test('deviceId lookup is case-insensitive', () async { + final meshData = createMeshData(); final container = createContainer(data: meshData); await container.read(devicesDataProvider.future); final detail = container.read(uspNodeDetailProvider('aa:bb:cc:dd:ee:01')); - expect(detail.node, masterNode); - expect(detail.connectedDevices, hasLength(2)); + expect(detail.node, isNotNull); + expect(detail.connectedClients, hasLength(2)); container.dispose(); }); @@ -123,32 +136,31 @@ void main() { test('GATEWAY lookup treats null parentNodeId as connected to gateway', () async { - const nonMeshData = DevicesData( - nodeModels: [ - NodeUIModel( + final device1NoParent = ClientDevice( + mac: '11:22:33:44:55:01', + ip: '192.168.1.100', + hostName: 'iPhone', + isActive: true, + connectionType: ConnectionType.wifi, + parentNodeId: null, // non-mesh: no parent + ); + final device2NoParent = ClientDevice( + mac: '11:22:33:44:55:02', + ip: '192.168.1.101', + hostName: 'MacBook', + isActive: true, + connectionType: ConnectionType.wired, + parentNodeId: null, + ); + + final nonMeshData = DevicesData( + meshNetwork: MeshNetwork( + master: MasterNode( deviceId: 'GATEWAY', model: 'MR7500', - isMaster: true, - ), - ], - deviceModels: [ - DeviceUIModel( - mac: '11:22:33:44:55:01', - ip: '192.168.1.100', - hostName: 'iPhone', - isActive: true, - isWifi: true, - parentNodeId: null, // non-mesh: no parent - ), - DeviceUIModel( - mac: '11:22:33:44:55:02', - ip: '192.168.1.101', - hostName: 'MacBook', - isActive: true, - isWifi: false, - parentNodeId: null, + connectedClients: [device1NoParent, device2NoParent], ), - ], + ), ); final container = createContainer(data: nonMeshData); @@ -156,25 +168,28 @@ void main() { final detail = container.read(uspNodeDetailProvider('GATEWAY')); - expect(detail.connectedDevices, hasLength(2)); + expect(detail.connectedClients, hasLength(2)); container.dispose(); }); test('GATEWAY lookup is case-insensitive', () async { - const nonMeshData = DevicesData( - nodeModels: [ - NodeUIModel(deviceId: 'GATEWAY', model: 'MR7500', isMaster: true), - ], - deviceModels: [ - DeviceUIModel( - mac: '11:22:33:44:55:01', - ip: '192.168.1.100', - hostName: 'Phone', - isActive: true, - isWifi: true, - parentNodeId: null, + final phone = ClientDevice( + mac: '11:22:33:44:55:01', + ip: '192.168.1.100', + hostName: 'Phone', + isActive: true, + connectionType: ConnectionType.wifi, + parentNodeId: null, + ); + + final nonMeshData = DevicesData( + meshNetwork: MeshNetwork( + master: MasterNode( + deviceId: 'GATEWAY', + model: 'MR7500', + connectedClients: [phone], ), - ], + ), ); final container = createContainer(data: nonMeshData); @@ -182,27 +197,28 @@ void main() { final detail = container.read(uspNodeDetailProvider('gateway')); - expect(detail.connectedDevices, hasLength(1)); + expect(detail.connectedClients, hasLength(1)); container.dispose(); }); // ----------------------------------------------------------------------- - // activeDeviceCount + // activeClientCount // ----------------------------------------------------------------------- - test('activeDeviceCount counts only active devices', () async { + test('activeClientCount counts only active devices', () async { + final meshData = createMeshData(); final container = createContainer(data: meshData); await container.read(devicesDataProvider.future); // Master has device1 (active) + device2 (active) = 2 final masterDetail = container.read(uspNodeDetailProvider('AA:BB:CC:DD:EE:01')); - expect(masterDetail.activeDeviceCount, 2); + expect(masterDetail.activeClientCount, 2); // Extender has device3 (inactive) = 0 final extenderDetail = container.read(uspNodeDetailProvider('AA:BB:CC:DD:EE:02')); - expect(extenderDetail.activeDeviceCount, 0); + expect(extenderDetail.activeClientCount, 0); container.dispose(); }); @@ -216,25 +232,31 @@ void main() { final detail = container.read(uspNodeDetailProvider('AA:BB:CC:DD:EE:01')); expect(detail.node, isNull); - expect(detail.connectedDevices, isEmpty); + expect(detail.connectedClients, isEmpty); container.dispose(); }); test('returns null node for unknown deviceId', () async { + final meshData = createMeshData(); final container = createContainer(data: meshData); await container.read(devicesDataProvider.future); final detail = container.read(uspNodeDetailProvider('UNKNOWN')); expect(detail.node, isNull); - expect(detail.connectedDevices, isEmpty); + expect(detail.connectedClients, isEmpty); container.dispose(); }); test('node with no connected devices returns empty list', () async { - const data = DevicesData( - nodeModels: [masterNode], - deviceModels: [], + final data = DevicesData( + meshNetwork: MeshNetwork( + master: MasterNode( + deviceId: 'AA:BB:CC:DD:EE:01', + model: 'MR7500', + connectedClients: [], + ), + ), ); final container = createContainer(data: data); @@ -242,9 +264,9 @@ void main() { final detail = container.read(uspNodeDetailProvider('AA:BB:CC:DD:EE:01')); - expect(detail.node, masterNode); - expect(detail.connectedDevices, isEmpty); - expect(detail.activeDeviceCount, 0); + expect(detail.node, isNotNull); + expect(detail.connectedClients, isEmpty); + expect(detail.activeClientCount, 0); container.dispose(); }); }); @@ -260,7 +282,16 @@ class _FakeDevicesNotifier extends AsyncNotifier _FakeDevicesNotifier(this._data); @override - Future build() async => _data ?? const DevicesData(); + Future build() async { + if (_data == null) { + return DevicesData( + meshNetwork: MeshNetwork( + master: MasterNode(deviceId: 'GATEWAY', model: 'Unknown'), + ), + ); + } + return _data; + } @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); diff --git a/test/page/wifi_settings/providers/usp_wifi_advanced_notifier_test.dart b/test/page/wifi_settings/providers/usp_wifi_advanced_notifier_test.dart index 7a7c05a69..c8f617351 100644 --- a/test/page/wifi_settings/providers/usp_wifi_advanced_notifier_test.dart +++ b/test/page/wifi_settings/providers/usp_wifi_advanced_notifier_test.dart @@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/core/usp/providers/usp_mutation_lock.dart'; +import 'package:privacy_gui/page/_shared/models/wifi_radio_ui_model.dart'; import 'package:privacy_gui/page/wifi_settings/providers/usp_wifi_advanced_provider.dart'; import 'package:privacy_gui/page/wifi_settings/providers/wifi_data_provider.dart'; import 'package:privacy_gui/page/wifi_settings/services/usp_wifi_advanced_service.dart'; @@ -10,6 +11,24 @@ import 'package:privacy_gui/page/wifi_settings/services/usp_wifi_advanced_servic class MockUspWifiAdvancedService extends Mock implements UspWifiAdvancedService {} +WifiRadioUIModel _radioModel({ + required String instancePath, + required String band, + required int channel, + required bool autoChannelEnable, +}) => + WifiRadioUIModel( + instancePath: instancePath, + band: band, + enable: true, + transmitPower: 100, + maxBitRate: 2402, + channel: channel, + autoChannelEnable: autoChannelEnable, + channelBandwidth: '80MHz', + supportedStandards: 'a,n,ac,ax', + ); + void main() { late MockUspWifiAdvancedService mockService; @@ -17,12 +36,14 @@ void main() { mockService = MockUspWifiAdvancedService(); }); - ProviderContainer createContainer() { + ProviderContainer createContainer({ + List radios = const [], + }) { final container = ProviderContainer( overrides: [ uspWifiAdvancedServiceProvider.overrideWithValue(mockService), uspMutationLockProvider.overrideWithValue(UspMutationLock()), - wifiDataProvider.overrideWith(() => _StubWifiDataNotifier()), + wifiDataProvider.overrideWith(() => _StubWifiDataNotifier(radios)), ], ); container.listen(uspWifiAdvancedProvider, (_, __) {}); @@ -184,6 +205,7 @@ void main() { verifyNever(() => mockService.setIeee80211hEnabled( radioPaths: any(named: 'radioPaths'), enabled: any(named: 'enabled'), + forceAutoChannelPaths: any(named: 'forceAutoChannelPaths'), )); container.dispose(); }); @@ -220,6 +242,7 @@ void main() { when(() => mockService.setIeee80211hEnabled( radioPaths: any(named: 'radioPaths'), enabled: any(named: 'enabled'), + forceAutoChannelPaths: any(named: 'forceAutoChannelPaths'), )).thenAnswer((_) async {}); final container = createContainer(); @@ -232,6 +255,118 @@ void main() { verify(() => mockService.setIeee80211hEnabled( radioPaths: ['Device.WiFi.Radio.1.', 'Device.WiFi.Radio.2.'], enabled: true, + forceAutoChannelPaths: const [], + )).called(1); + container.dispose(); + }); + + test( + 'disabling DFS forces AutoChannelEnable on radios parked on a DFS ' + 'channel', () async { + when(() => mockService.fetchIeee80211h()).thenAnswer((_) async => { + 'Device.WiFi.Radio.1.': true, + 'Device.WiFi.Radio.2.': true, + }); + when(() => mockService.setIeee80211hEnabled( + radioPaths: any(named: 'radioPaths'), + enabled: any(named: 'enabled'), + forceAutoChannelPaths: any(named: 'forceAutoChannelPaths'), + )).thenAnswer((_) async {}); + + final container = createContainer(radios: [ + // 2.4 GHz on ch 6 — never DFS, must not be forced. + _radioModel( + instancePath: 'Device.WiFi.Radio.1.', + band: '2.4GHz', + channel: 6, + autoChannelEnable: false, + ), + // 5 GHz manually parked on DFS ch 100 — must be forced to auto. + _radioModel( + instancePath: 'Device.WiFi.Radio.2.', + band: '5GHz', + channel: 100, + autoChannelEnable: false, + ), + ]); + await Future.delayed(Duration.zero); + + final notifier = container.read(uspWifiAdvancedProvider.notifier); + notifier.setDfsEnabled(false); + await notifier.save(); + + verify(() => mockService.setIeee80211hEnabled( + radioPaths: ['Device.WiFi.Radio.1.', 'Device.WiFi.Radio.2.'], + enabled: false, + forceAutoChannelPaths: ['Device.WiFi.Radio.2.'], + )).called(1); + container.dispose(); + }); + + test( + 'disabling DFS does not force auto-channel when the 5 GHz radio is on ' + 'a non-DFS channel', () async { + when(() => mockService.fetchIeee80211h()).thenAnswer((_) async => { + 'Device.WiFi.Radio.2.': true, + }); + when(() => mockService.setIeee80211hEnabled( + radioPaths: any(named: 'radioPaths'), + enabled: any(named: 'enabled'), + forceAutoChannelPaths: any(named: 'forceAutoChannelPaths'), + )).thenAnswer((_) async {}); + + final container = createContainer(radios: [ + // 5 GHz on ch 36 (non-DFS) — no remediation needed. + _radioModel( + instancePath: 'Device.WiFi.Radio.2.', + band: '5GHz', + channel: 36, + autoChannelEnable: false, + ), + ]); + await Future.delayed(Duration.zero); + + final notifier = container.read(uspWifiAdvancedProvider.notifier); + notifier.setDfsEnabled(false); + await notifier.save(); + + verify(() => mockService.setIeee80211hEnabled( + radioPaths: ['Device.WiFi.Radio.2.'], + enabled: false, + forceAutoChannelPaths: const [], + )).called(1); + container.dispose(); + }); + + test('disabling DFS skips a radio already on auto-channel', () async { + when(() => mockService.fetchIeee80211h()).thenAnswer((_) async => { + 'Device.WiFi.Radio.2.': true, + }); + when(() => mockService.setIeee80211hEnabled( + radioPaths: any(named: 'radioPaths'), + enabled: any(named: 'enabled'), + forceAutoChannelPaths: any(named: 'forceAutoChannelPaths'), + )).thenAnswer((_) async {}); + + final container = createContainer(radios: [ + // Auto-channel already on: firmware will pick a legal channel itself. + _radioModel( + instancePath: 'Device.WiFi.Radio.2.', + band: '5GHz', + channel: 100, + autoChannelEnable: true, + ), + ]); + await Future.delayed(Duration.zero); + + final notifier = container.read(uspWifiAdvancedProvider.notifier); + notifier.setDfsEnabled(false); + await notifier.save(); + + verify(() => mockService.setIeee80211hEnabled( + radioPaths: ['Device.WiFi.Radio.2.'], + enabled: false, + forceAutoChannelPaths: const [], )).called(1); container.dispose(); }); @@ -243,6 +378,7 @@ void main() { when(() => mockService.setIeee80211hEnabled( radioPaths: any(named: 'radioPaths'), enabled: any(named: 'enabled'), + forceAutoChannelPaths: any(named: 'forceAutoChannelPaths'), )).thenThrow(const InvalidInputError(detail: 'read-only')); final container = createContainer(); @@ -262,6 +398,7 @@ void main() { when(() => mockService.setIeee80211hEnabled( radioPaths: any(named: 'radioPaths'), enabled: any(named: 'enabled'), + forceAutoChannelPaths: any(named: 'forceAutoChannelPaths'), )).thenAnswer((_) async {}); final container = createContainer(); @@ -326,6 +463,15 @@ void main() { // --------------------------------------------------------------------------- class _StubWifiDataNotifier extends WifiDataNotifier { + _StubWifiDataNotifier(this._radios); + + final List _radios; + @override - Future build() async => const WifiData.empty(); + Future build() async => _radios.isEmpty + ? const WifiData.empty() + : WifiData( + codegenContext: WifiCodegenContext.empty, + radioModels: _radios, + ); } diff --git a/test/page/wifi_settings/providers/usp_wifi_settings_notifier_test.dart b/test/page/wifi_settings/providers/usp_wifi_settings_notifier_test.dart index e214c6b7d..f3f589e82 100644 --- a/test/page/wifi_settings/providers/usp_wifi_settings_notifier_test.dart +++ b/test/page/wifi_settings/providers/usp_wifi_settings_notifier_test.dart @@ -137,6 +137,36 @@ void main() { container.dispose(); }); + // Regression: issue #1119. The provider must NOT gate its fetch on the raw + // usp.isAuthenticated flag — in Remote Assistance that flag stays false by + // design (authToken bypass), yet the WiFi data layer serves fine. Auth is + // enforced by the router, not here. + test('fetch proceeds when usp.isAuthenticated is false (RA bypass)', + () async { + when(() => mockUsp.isAuthenticated).thenReturn(false); + final networks = WifiSettingsTestData.createNetworks(); + when(() => mockService.buildWifiNetworks( + ssids: any(named: 'ssids'), + accessPoints: any(named: 'accessPoints'), + radios: any(named: 'radios'), + )).thenReturn(networks); + when(() => mockService.buildQuickSetupNetworks(any())).thenReturn(( + main: WifiSettingsTestData.createQuickSetupAggregate(), + guest: null, + isQuickSetup: true, + )); + + final container = createContainer(); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + final state = container.read(uspWifiSettingsProvider); + expect(state.status.error, isNull); + expect(state.settings.current.networks, hasLength(3)); + expect(state.status.isLoading, isFalse); + container.dispose(); + }); + // ----------------------------------------------------------------------- // updateNetworkField // ----------------------------------------------------------------------- @@ -593,8 +623,9 @@ void main() { final notifier = container.read(uspWifiSettingsProvider.notifier); notifier.updateNetworkField('Device.WiFi.SSID.1.', ssid: 'Changed'); - expect( - () => notifier.save(), + // Use await + expectLater so the finally block completes before dispose + await expectLater( + notifier.save(), throwsA(isA()), ); container.dispose(); diff --git a/test/page/wifi_settings/providers/usp_wifi_settings_state_test.dart b/test/page/wifi_settings/providers/usp_wifi_settings_state_test.dart index 63121bdbe..202fb038b 100644 --- a/test/page/wifi_settings/providers/usp_wifi_settings_state_test.dart +++ b/test/page/wifi_settings/providers/usp_wifi_settings_state_test.dart @@ -534,8 +534,8 @@ void main() { enabled: true, ssid: 'OpenNet', password: '', - securityMode: 'Enhanced-Open', - supportedSecurityModes: ['None', 'Enhanced-Open'], + securityMode: 'OWE', + supportedSecurityModes: ['None', 'OWE'], ); expect(openPending.isPasswordRequired(null), isFalse); }); @@ -555,4 +555,41 @@ void main() { expect(updated.status.isSaving, isTrue); }); }); + + // ------------------------------------------------------------------------- + // WifiNetworkUIModel.isOpenSecurity + // + // 'OWE' is the TR-181 token for Enhanced Open — firmware advertises and + // accepts only 'OWE' (see issue #1073). It must be treated as an open + // (no-password) mode; 'Enhanced-Open' is not a valid firmware token. + // ------------------------------------------------------------------------- + + group('WifiNetworkUIModel.isOpenSecurity', () { + test('true for None', () { + final n = WifiSettingsTestData.createNetworkUIModel(securityMode: 'None'); + expect(n.isOpenSecurity, isTrue); + }); + + test('true for empty string', () { + final n = WifiSettingsTestData.createNetworkUIModel(securityMode: ''); + expect(n.isOpenSecurity, isTrue); + }); + + test('true for OWE (Enhanced Open)', () { + final n = WifiSettingsTestData.createNetworkUIModel(securityMode: 'OWE'); + expect(n.isOpenSecurity, isTrue); + }); + + test('false for WPA2-Personal', () { + final n = WifiSettingsTestData.createNetworkUIModel( + securityMode: 'WPA2-Personal'); + expect(n.isOpenSecurity, isFalse); + }); + + test('false for WPA3-Personal-Transition', () { + final n = WifiSettingsTestData.createNetworkUIModel( + securityMode: 'WPA3-Personal-Transition'); + expect(n.isOpenSecurity, isFalse); + }); + }); } diff --git a/test/page/wifi_settings/services/usp_wifi_advanced_service_test.dart b/test/page/wifi_settings/services/usp_wifi_advanced_service_test.dart index e4adaae00..70dd7f499 100644 --- a/test/page/wifi_settings/services/usp_wifi_advanced_service_test.dart +++ b/test/page/wifi_settings/services/usp_wifi_advanced_service_test.dart @@ -6,6 +6,42 @@ import 'package:privacy_gui/page/wifi_settings/services/usp_wifi_advanced_servic class MockUspClient extends Mock implements UspClient {} +// WASM v0.11.0 set-result shapes consumed by UspResultParser.parseSetResult. +Map _setSuccess() => { + 'success': true, + 'result': {'data': {}}, + }; + +Map _setPartial({ + String path = 'Device.WiFi.Radio.2.AutoChannelEnable', + int errorCode = 7008, + String errorMessage = 'Invalid value', +}) => + { + 'success': true, + 'result': { + 'data': {'Device.WiFi.Radio.1.IEEE80211hEnabled': false}, + 'error': { + path: {'errorCode': errorCode, 'errorMessage': errorMessage}, + }, + }, + }; + +Map _setFailure({ + String path = 'bulk_operation', + int errorCode = 7004, + String errorMessage = 'Operation failed', +}) => + { + 'success': false, + 'result': { + 'data': {}, + 'error': { + path: {'errorCode': errorCode, 'errorMessage': errorMessage}, + }, + }, + }; + void main() { late MockUspClient mockUsp; late UspWifiAdvancedService svc; @@ -70,7 +106,7 @@ void main() { group('UspWifiAdvancedService - setIeee80211hEnabled', () { test('sets IEEE80211hEnabled on all provided radio paths', () async { - when(() => mockUsp.set(any())).thenAnswer((_) async => {}); + when(() => mockUsp.set(any())).thenAnswer((_) async => _setSuccess()); await svc.setIeee80211hEnabled( radioPaths: ['Device.WiFi.Radio.1.', 'Device.WiFi.Radio.2.'], @@ -90,7 +126,7 @@ void main() { }); test('sends false for all radios when disabling', () async { - when(() => mockUsp.set(any())).thenAnswer((_) async => {}); + when(() => mockUsp.set(any())).thenAnswer((_) async => _setSuccess()); await svc.setIeee80211hEnabled( radioPaths: ['Device.WiFi.Radio.1.'], @@ -114,6 +150,65 @@ void main() { throwsA(isA()), ); }); + + test('forces AutoChannelEnable in the same set for given paths', () async { + when(() => mockUsp.set(any())).thenAnswer((_) async => _setSuccess()); + + await svc.setIeee80211hEnabled( + radioPaths: ['Device.WiFi.Radio.1.', 'Device.WiFi.Radio.2.'], + enabled: false, + forceAutoChannelPaths: ['Device.WiFi.Radio.2.'], + ); + + // Radio.2 (parked on a DFS channel) also gets AutoChannelEnable=true; + // Radio.1 keeps its channel settings. + verify(() => mockUsp.set({ + 'Device.WiFi.Radio.1.IEEE80211hEnabled': false, + 'Device.WiFi.Radio.2.IEEE80211hEnabled': false, + 'Device.WiFi.Radio.2.AutoChannelEnable': true, + })).called(1); + }); + + test('empty forceAutoChannelPaths writes no AutoChannelEnable', () async { + when(() => mockUsp.set(any())).thenAnswer((_) async => _setSuccess()); + + await svc.setIeee80211hEnabled( + radioPaths: ['Device.WiFi.Radio.1.'], + enabled: false, + ); + + verify(() => mockUsp.set({ + 'Device.WiFi.Radio.1.IEEE80211hEnabled': false, + })).called(1); + }); + + test('throws UspPartialFailureError on firmware partial rejection', + () async { + // Firmware accepts IEEE80211hEnabled but rejects the forced + // AutoChannelEnable write — must not be silently swallowed. + when(() => mockUsp.set(any())).thenAnswer((_) async => _setPartial()); + + expect( + () => svc.setIeee80211hEnabled( + radioPaths: ['Device.WiFi.Radio.1.', 'Device.WiFi.Radio.2.'], + enabled: false, + forceAutoChannelPaths: ['Device.WiFi.Radio.2.'], + ), + throwsA(isA()), + ); + }); + + test('throws UspCompleteFailureError on complete failure', () async { + when(() => mockUsp.set(any())).thenAnswer((_) async => _setFailure()); + + expect( + () => svc.setIeee80211hEnabled( + radioPaths: ['Device.WiFi.Radio.1.'], + enabled: false, + ), + throwsA(isA()), + ); + }); }); // ------------------------------------------------------------------------- diff --git a/test/page/wifi_settings/services/usp_wifi_data_service_test.dart b/test/page/wifi_settings/services/usp_wifi_data_service_test.dart index 79cec7fc7..b5384297d 100644 --- a/test/page/wifi_settings/services/usp_wifi_data_service_test.dart +++ b/test/page/wifi_settings/services/usp_wifi_data_service_test.dart @@ -2,6 +2,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/core/usp/services/usp_client.dart'; +import 'package:privacy_gui/generated/wi_fi_radios.g.dart'; +import 'package:privacy_gui/generated/wi_fi_ssids.g.dart'; import 'package:privacy_gui/page/wifi_settings/services/usp_wifi_data_service.dart'; class MockUspClient extends Mock implements UspClient {} @@ -45,16 +47,19 @@ Map _buildSsidsResponse() => { 'Device.WiFi.SSID.1.Status': 'Up', 'Device.WiFi.SSID.1.BSSID': 'AA:BB:CC:DD:EE:01', 'Device.WiFi.SSID.1.LowerLayers': 'Device.WiFi.Radio.1.', + 'Device.WiFi.SSID.1.Alias': 'wifi-2g', 'Device.WiFi.SSID.2.SSID': 'Home', 'Device.WiFi.SSID.2.Enable': true, 'Device.WiFi.SSID.2.Status': 'Up', 'Device.WiFi.SSID.2.BSSID': 'AA:BB:CC:DD:EE:02', 'Device.WiFi.SSID.2.LowerLayers': 'Device.WiFi.Radio.2.', + 'Device.WiFi.SSID.2.Alias': 'wifi-5g', 'Device.WiFi.SSID.3.SSID': 'Home-Guest', 'Device.WiFi.SSID.3.Enable': true, 'Device.WiFi.SSID.3.Status': 'Up', 'Device.WiFi.SSID.3.BSSID': 'AA:BB:CC:DD:EE:03', 'Device.WiFi.SSID.3.LowerLayers': 'Device.WiFi.Radio.1.', + 'Device.WiFi.SSID.3.Alias': 'wifi-2g-guest', }; /// Raw USP response map for 3 access points. @@ -111,6 +116,39 @@ void _stubAllFetches(MockUspClient mockUsp) { }); } +/// Stubs fetches for a single 5 GHz radio whose `PossibleChannels` value is +/// [possibleChannels]. Used to exercise the shared `parsePossibleChannels` via +/// the public `fetch()` entry point. +void _stubRadioWithPossibleChannels( + MockUspClient mockUsp, + String possibleChannels, +) { + when(() => mockUsp.get(any(), priority: any(named: 'priority'))) + .thenAnswer((invocation) async { + final paths = invocation.positionalArguments[0] as List; + final first = paths.isNotEmpty ? paths.first : ''; + if (first.startsWith('Device.WiFi.Radio.')) { + return { + 'Device.WiFi.Radio.1.Enable': true, + 'Device.WiFi.Radio.1.Status': 'Up', + 'Device.WiFi.Radio.1.Channel': 36, + 'Device.WiFi.Radio.1.OperatingFrequencyBand': '5GHz', + 'Device.WiFi.Radio.1.OperatingChannelBandwidth': '80MHz', + 'Device.WiFi.Radio.1.PossibleChannels': possibleChannels, + 'Device.WiFi.Radio.1.OperatingStandards': 'ax', + 'Device.WiFi.Radio.1.SupportedStandards': 'a,n,ac,ax', + 'Device.WiFi.Radio.1.TransmitPower': 100, + 'Device.WiFi.Radio.1.MaxBitRate': 2400, + 'Device.WiFi.Radio.1.AutoChannelEnable': false, + 'Device.WiFi.Radio.1.IEEE80211hEnabled': false, + 'Device.WiFi.Radio.1.SupportedOperatingChannelBandwidths': + 'Auto,20MHz,40MHz,80MHz', + }; + } + return {}; + }); +} + void main() { late MockUspClient mockUsp; late UspWifiDataService svc; @@ -167,6 +205,9 @@ void main() { expect(radio1.accessPoints.length, 2); expect(radio1.accessPoints[0].ssidName, 'Home'); expect(radio1.accessPoints[1].ssidName, 'Home-Guest'); + // Guest detection via alias suffix (-guest), not instance ordering. + expect(radio1.accessPoints[0].isGuest, isFalse); + expect(radio1.accessPoints[1].isGuest, isTrue); // Radio 2 (5GHz): SSID.2 → AP.2 final radio2 = result.radioModels[1]; @@ -193,6 +234,29 @@ void main() { expect(radio2.maxBitRate, 2400); }); + test('threads IEEE80211hEnabled into radio model isDfsEnabled', () async { + _stubAllFetches(mockUsp); + + final result = await svc.fetch(); + + // Both stub radios have IEEE80211hEnabled = false. The dashboard radio + // model carries the per-radio DFS flag verbatim (channel filtering happens + // in the channel dialog, not here). + expect(result.radioModels[0].isDfsEnabled, isFalse); + expect(result.radioModels[1].isDfsEnabled, isFalse); + }); + + test('AC7: enriches possibleChannels from PossibleChannels at fetch time', + () async { + _stubAllFetches(mockUsp); + + final result = await svc.fetch(); + + // Radio 1 stub PossibleChannels = "1,6,11"; Radio 2 = "36,40,44,48". + expect(result.radioModels[0].possibleChannels, [1, 6, 11]); + expect(result.radioModels[1].possibleChannels, [36, 40, 44, 48]); + }); + test('handles empty collections', () async { when(() => mockUsp.get(any(), priority: any(named: 'priority'))) .thenAnswer((_) async => {}); @@ -204,4 +268,216 @@ void main() { expect(result.connectionDetailMap, isEmpty); }); }); + + // ------------------------------------------------------------------------- + // PossibleChannels parsing — range notation, sentinels, malformed tokens + // (W-3 / W-4). Exercises the shared parsePossibleChannels via fetch(). + // ------------------------------------------------------------------------- + + group('PossibleChannels parsing', () { + test('expands mixed range + single notation ("1-3,6")', () async { + _stubRadioWithPossibleChannels(mockUsp, '1-3,6'); + + final result = await svc.fetch(); + + expect(result.radioModels.single.possibleChannels, [1, 2, 3, 6]); + }); + + test('expands full range notation ("1-13")', () async { + _stubRadioWithPossibleChannels(mockUsp, '1-13'); + + final result = await svc.fetch(); + + expect( + result.radioModels.single.possibleChannels, + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], + ); + }); + + test('inverted range ("11-1") degrades to empty without throwing', + () async { + _stubRadioWithPossibleChannels(mockUsp, '11-1'); + + final result = await svc.fetch(); + + expect(result.radioModels.single.possibleChannels, isEmpty); + }); + + test('filters out TR-181 "0" auto/any sentinel ("0,1,6,11")', () async { + _stubRadioWithPossibleChannels(mockUsp, '0,1,6,11'); + + final result = await svc.fetch(); + + expect(result.radioModels.single.possibleChannels, [1, 6, 11]); + }); + + test('skips malformed range token ("1-2-3") without throwing', () async { + _stubRadioWithPossibleChannels(mockUsp, '1-2-3'); + + final result = await svc.fetch(); + + // The malformed token yields nothing; parsing does not throw. + expect(result.radioModels.single.possibleChannels, isEmpty); + }); + }); + + // ------------------------------------------------------------------------- + // buildBssidToBandMap + // ------------------------------------------------------------------------- + + group('buildBssidToBandMap', () { + test('maps BSSID to band via SSID.LowerLayers → Radio', () { + final ssids = WiFiSsids(items: [ + WiFiSsid( + instancePath: 'Device.WiFi.SSID.1.', + ssid: 'Home', + enable: true, + status: 'Up', + bssid: 'AA:BB:CC:DD:EE:01', + lowerLayers: 'Device.WiFi.Radio.1.', + ), + WiFiSsid( + instancePath: 'Device.WiFi.SSID.2.', + ssid: 'Home', + enable: true, + status: 'Up', + bssid: 'AA:BB:CC:DD:EE:02', + lowerLayers: 'Device.WiFi.Radio.2.', + ), + ]); + final radios = WiFiRadios(items: [ + WiFiRadio( + instancePath: 'Device.WiFi.Radio.1.', + enable: true, + status: 'Up', + channel: 6, + operatingFrequencyBand: '2.4GHz', + operatingChannelBandwidth: '20MHz', + possibleChannels: '1,6,11', + operatingStandards: 'n', + supportedStandards: 'b,g,n', + transmitPower: 100, + maxBitRate: 300, + autoChannelEnable: true, + ieee80211hEnabled: false, + supportedOperatingChannelBandwidths: '20MHz,40MHz', + ), + WiFiRadio( + instancePath: 'Device.WiFi.Radio.2.', + enable: true, + status: 'Up', + channel: 36, + operatingFrequencyBand: '5GHz', + operatingChannelBandwidth: '80MHz', + possibleChannels: '36,40,44,48', + operatingStandards: 'ax', + supportedStandards: 'a,n,ac,ax', + transmitPower: 100, + maxBitRate: 2400, + autoChannelEnable: false, + ieee80211hEnabled: false, + supportedOperatingChannelBandwidths: '20MHz,40MHz,80MHz', + ), + ]); + + final result = + UspWifiDataService.buildBssidToBandMap(ssids: ssids, radios: radios); + + expect(result, { + 'AA:BB:CC:DD:EE:01': '2.4GHz', + 'AA:BB:CC:DD:EE:02': '5GHz', + }); + }); + + test('normalizes BSSID to uppercase', () { + final ssids = WiFiSsids(items: [ + WiFiSsid( + instancePath: 'Device.WiFi.SSID.1.', + ssid: 'Home', + enable: true, + status: 'Up', + bssid: 'aa:bb:cc:dd:ee:01', // lowercase + lowerLayers: 'Device.WiFi.Radio.1.', + ), + ]); + final radios = WiFiRadios(items: [ + WiFiRadio( + instancePath: 'Device.WiFi.Radio.1.', + enable: true, + status: 'Up', + channel: 6, + operatingFrequencyBand: '2.4GHz', + operatingChannelBandwidth: '20MHz', + possibleChannels: '1,6,11', + operatingStandards: 'n', + supportedStandards: 'b,g,n', + transmitPower: 100, + maxBitRate: 300, + autoChannelEnable: true, + ieee80211hEnabled: false, + supportedOperatingChannelBandwidths: '20MHz,40MHz', + ), + ]); + + final result = + UspWifiDataService.buildBssidToBandMap(ssids: ssids, radios: radios); + + expect(result.keys.single, 'AA:BB:CC:DD:EE:01'); + }); + + test('skips SSID with empty BSSID', () { + final ssids = WiFiSsids(items: [ + WiFiSsid( + instancePath: 'Device.WiFi.SSID.1.', + ssid: 'Home', + enable: true, + status: 'Up', + bssid: '', // empty + lowerLayers: 'Device.WiFi.Radio.1.', + ), + ]); + final radios = WiFiRadios(items: [ + WiFiRadio( + instancePath: 'Device.WiFi.Radio.1.', + enable: true, + status: 'Up', + channel: 6, + operatingFrequencyBand: '2.4GHz', + operatingChannelBandwidth: '20MHz', + possibleChannels: '1,6,11', + operatingStandards: 'n', + supportedStandards: 'b,g,n', + transmitPower: 100, + maxBitRate: 300, + autoChannelEnable: true, + ieee80211hEnabled: false, + supportedOperatingChannelBandwidths: '20MHz,40MHz', + ), + ]); + + final result = + UspWifiDataService.buildBssidToBandMap(ssids: ssids, radios: radios); + + expect(result, isEmpty); + }); + + test('returns empty map when no radios', () { + final ssids = WiFiSsids(items: [ + WiFiSsid( + instancePath: 'Device.WiFi.SSID.1.', + ssid: 'Home', + enable: true, + status: 'Up', + bssid: 'AA:BB:CC:DD:EE:01', + lowerLayers: 'Device.WiFi.Radio.1.', + ), + ]); + final radios = WiFiRadios(items: []); + + final result = + UspWifiDataService.buildBssidToBandMap(ssids: ssids, radios: radios); + + expect(result, isEmpty); + }); + }); } diff --git a/test/page/wifi_settings/services/usp_wifi_settings_service_test.dart b/test/page/wifi_settings/services/usp_wifi_settings_service_test.dart index 1be20080e..783005950 100644 --- a/test/page/wifi_settings/services/usp_wifi_settings_service_test.dart +++ b/test/page/wifi_settings/services/usp_wifi_settings_service_test.dart @@ -146,7 +146,9 @@ void main() { transmitPower: 100, maxBitRate: 2402, autoChannelEnable: false, - ieee80211hEnabled: false, + // DFS enabled so DFS channels (52–64) survive filtering — this test + // exercises bonding-group rules, not DFS filtering. + ieee80211hEnabled: true, supportedOperatingChannelBandwidths: 'Auto,20MHz,40MHz,80MHz,160MHz', ), ]); @@ -176,6 +178,99 @@ void main() { expect(bwMap['160MHz'], [36, 40, 44, 48, 52, 56, 60, 64]); }); + // ----------------------------------------------------------------------- + // DFS channel filtering (#1025). When IEEE80211hEnabled is false, 5 GHz + // DFS channels (52–64, 100–144) must be stripped from BOTH possibleChannels + // and availableChannelsPerBandwidth so the dropdown and the "N channels + // available" counts stay consistent. + // ----------------------------------------------------------------------- + + WiFiSsids singleSsid() => WiFiSsids(items: [ + WiFiSsid( + instancePath: 'Device.WiFi.SSID.1.', + ssid: 'DfsNet', + enable: true, + status: 'Up', + bssid: 'AA:BB:CC:DD:EE:FF', + lowerLayers: 'Device.WiFi.Radio.1.', + ), + ]); + + WiFiAccessPoints singleAp() => WiFiAccessPoints(items: [ + WiFiAccessPoint( + instancePath: 'Device.WiFi.AccessPoint.1.', + enable: true, + status: 'Enabled', + modesSupported: 'WPA2-Personal', + securityModeEnabled: 'WPA2-Personal', + encryptionMode: 'AES', + keyPassphrase: 'pass', + ssidAdvertisementEnabled: true, + ssidReference: 'Device.WiFi.SSID.1.', + ), + ]); + + WiFiRadios fiveGhzRadio({required bool dfsEnabled}) => WiFiRadios(items: [ + WiFiRadio( + instancePath: 'Device.WiFi.Radio.1.', + enable: true, + status: 'Up', + channel: 36, + operatingFrequencyBand: '5GHz', + operatingChannelBandwidth: '80MHz', + possibleChannels: '36,40,44,48,52,56,60,64,100,104,108,112', + operatingStandards: 'ax', + supportedStandards: 'a,n,ac,ax', + transmitPower: 100, + maxBitRate: 2402, + autoChannelEnable: false, + ieee80211hEnabled: dfsEnabled, + supportedOperatingChannelBandwidths: 'Auto,20MHz,40MHz,80MHz', + ), + ]); + + test('DFS disabled on 5 GHz strips DFS channels from possibleChannels', () { + final networks = svc.buildWifiNetworks( + ssids: singleSsid(), + accessPoints: singleAp(), + radios: fiveGhzRadio(dfsEnabled: false), + ); + + // Only non-DFS UNII-1 channels remain. + expect(networks.first.possibleChannels, [36, 40, 44, 48]); + }); + + test('DFS disabled on 5 GHz strips DFS from availableChannelsPerBandwidth', + () { + final networks = svc.buildWifiNetworks( + ssids: singleSsid(), + accessPoints: singleAp(), + radios: fiveGhzRadio(dfsEnabled: false), + ); + + final bwMap = networks.first.availableChannelsPerBandwidth; + expect(bwMap['Auto'], [36, 40, 44, 48]); + expect(bwMap['20MHz'], [36, 40, 44, 48]); + // No DFS channel should appear under any bandwidth. + for (final channels in bwMap.values) { + expect(channels.any((c) => c >= 52), isFalse, + reason: 'DFS channel leaked into bandwidth map'); + } + }); + + test('DFS enabled on 5 GHz retains DFS channels', () { + final networks = svc.buildWifiNetworks( + ssids: singleSsid(), + accessPoints: singleAp(), + radios: fiveGhzRadio(dfsEnabled: true), + ); + + expect( + networks.first.possibleChannels, + [36, 40, 44, 48, 52, 56, 60, 64, 100, 104, 108, 112], + ); + }); + test('empty supportedOperatingChannelBandwidths falls back to defaults', () { final ssids = WiFiSsids(items: [ @@ -774,10 +869,10 @@ void main() { }); // ------------------------------------------------------------------------- - // toggleRadio + // toggleSsidsByName — writes SSID.Enable + matched AccessPoint.Enable (#972) // ------------------------------------------------------------------------- - group('toggleRadio', () { + group('toggleSsidsByName', () { late MockUspClient mockUsp; late UspWifiSettingsService writeSvc; @@ -786,34 +881,79 @@ void main() { writeSvc = UspWifiSettingsService(mockUsp); }); - test('succeeds on UspSuccess', () async { + WiFiSsid ssid(String path, String name, String radio) => WiFiSsid( + instancePath: path, + ssid: name, + enable: true, + status: 'Up', + bssid: '', + lowerLayers: radio, + ); + WiFiAccessPoint ap(String path, String ssidRef) => WiFiAccessPoint( + instancePath: path, + alias: '', + enable: true, + status: 'Up', + modesSupported: '', + securityModeEnabled: '', + encryptionMode: '', + keyPassphrase: '', + ssidAdvertisementEnabled: true, + ssidReference: ssidRef, + ); + + Set capturedKeys() { + final captured = verify(() => mockUsp.set(captureAny(), + allowPartial: any(named: 'allowPartial'))).captured; + final keys = {}; + for (final arg in captured) { + if (arg is Map) keys.addAll(arg.keys.cast()); + } + return keys; + } + + test('toggles matched SSIDs and their AccessPoints across bands', () async { when(() => mockUsp.set(any(), allowPartial: any(named: 'allowPartial'))) .thenAnswer((_) async => uspSuccess()); - await writeSvc.toggleRadio('Device.WiFi.Radio.1.', true); - - verify(() => mockUsp.set(any(), allowPartial: any(named: 'allowPartial'))) - .called(1); - }); + final ssids = WiFiSsids(items: [ + ssid('Device.WiFi.SSID.1.', 'Home', 'Device.WiFi.Radio.1.'), + ssid('Device.WiFi.SSID.2.', 'Home', 'Device.WiFi.Radio.2.'), + ssid('Device.WiFi.SSID.3.', 'Home-Guest', 'Device.WiFi.Radio.1.'), + ]); + final aps = WiFiAccessPoints(items: [ + ap('Device.WiFi.AccessPoint.1.', 'Device.WiFi.SSID.1.'), + ap('Device.WiFi.AccessPoint.2.', 'Device.WiFi.SSID.2.'), + ap('Device.WiFi.AccessPoint.3.', 'Device.WiFi.SSID.3.'), + ]); - test('throws UspCompleteFailureError on UspFailure', () async { - when(() => mockUsp.set(any(), allowPartial: any(named: 'allowPartial'))) - .thenAnswer((_) async => uspFailure()); + final count = await writeSvc.toggleSsidsByName(ssids, aps, 'Home', false); - expect( - () => writeSvc.toggleRadio('Device.WiFi.Radio.1.', true), - throwsA(isA()), - ); + expect(count, 2); // SSID.1 + SSID.2 (both named "Home") + final keys = capturedKeys(); + expect(keys, contains('Device.WiFi.SSID.1.Enable')); + expect(keys, contains('Device.WiFi.SSID.2.Enable')); + expect(keys, contains('Device.WiFi.AccessPoint.1.Enable')); + expect(keys, contains('Device.WiFi.AccessPoint.2.Enable')); + // The guest network (SSID.3) must be untouched. + expect(keys, isNot(contains('Device.WiFi.SSID.3.Enable'))); + expect(keys, isNot(contains('Device.WiFi.AccessPoint.3.Enable'))); }); - test('maps transport error to ServiceError', () async { - when(() => mockUsp.set(any(), allowPartial: any(named: 'allowPartial'))) - .thenThrow('Set failed: Transport error: HTTP error: HTTP 504'); + test('returns 0 and issues no writes when no SSID matches', () async { + final ssids = WiFiSsids(items: [ + ssid('Device.WiFi.SSID.1.', 'Home', 'Device.WiFi.Radio.1.'), + ]); + final aps = WiFiAccessPoints(items: [ + ap('Device.WiFi.AccessPoint.1.', 'Device.WiFi.SSID.1.'), + ]); - expect( - () => writeSvc.toggleRadio('Device.WiFi.Radio.1.', true), - throwsA(isA()), - ); + final count = + await writeSvc.toggleSsidsByName(ssids, aps, 'Nonexistent', false); + + expect(count, 0); + verifyNever( + () => mockUsp.set(any(), allowPartial: any(named: 'allowPartial'))); }); }); @@ -939,6 +1079,98 @@ void main() { verifyNever( () => mockUsp.set(any(), allowPartial: any(named: 'allowPartial'))); }); + + test('enable-only toggle writes SSID.Enable + AP.Enable, not security', + () async { + when(() => mockUsp.set(any(), allowPartial: any(named: 'allowPartial'))) + .thenAnswer((_) async => uspSuccess()); + + final original = [makeNetwork(enabled: true)]; + final current = [makeNetwork(enabled: false)]; + + await writeSvc.saveAdvanced(original: original, current: current); + + final captured = verify(() => mockUsp.set(captureAny(), + allowPartial: any(named: 'allowPartial'))).captured; + final keys = {}; + for (final arg in captured) { + if (arg is Map) keys.addAll(arg.keys.cast()); + } + // Both enable layers are written… + expect(keys, contains('Device.WiFi.SSID.1.Enable')); + expect(keys, contains('Device.WiFi.AccessPoint.1.Enable')); + // …but the security/advertisement params are NOT re-sent on a pure + // enable toggle (mirrors saveQuickSetup gating). + expect(keys, + isNot(contains('Device.WiFi.AccessPoint.1.Security.KeyPassphrase'))); + expect(keys, + isNot(contains('Device.WiFi.AccessPoint.1.Security.ModeEnabled'))); + expect( + keys, + isNot( + contains('Device.WiFi.AccessPoint.1.SSIDAdvertisementEnabled'))); + }); + + // ----------------------------------------------------------------------- + // 6 GHz security override (#1073, #1142) — saveAdvanced must apply the + // same _securityModeFor6GHz coercion as saveQuickSetup, so both save + // paths write a firmware-valid mode on 6 GHz. + // ----------------------------------------------------------------------- + + /// Returns the value written to AccessPoint.1 Security.ModeEnabled after a + /// mode change on the given band, or null if it was never sent. + Future capturedModeEnabled({ + required String band, + required String selectedMode, + }) async { + when(() => mockUsp.set(any(), allowPartial: any(named: 'allowPartial'))) + .thenAnswer((_) async => uspSuccess()); + // Baseline mode differs from every selectedMode under test so the + // security diff always fires (WPA2-in == WPA2-baseline would send nothing). + final original = [ + makeNetwork(band: band, securityMode: 'WPA3-Personal-Transition') + ]; + final current = [makeNetwork(band: band, securityMode: selectedMode)]; + + await writeSvc.saveAdvanced(original: original, current: current); + + final captured = verify(() => mockUsp.set(captureAny(), + allowPartial: any(named: 'allowPartial'))).captured; + const key = 'Device.WiFi.AccessPoint.1.Security.ModeEnabled'; + for (final arg in captured) { + if (arg is Map && arg.containsKey(key)) return arg[key] as String?; + } + return null; + } + + test('6 GHz + OWE selected → sends OWE verbatim', () async { + expect( + await capturedModeEnabled(band: '6GHz', selectedMode: 'OWE'), + 'OWE', + ); + }); + + test('6 GHz + None (open) selected → normalized to OWE', () async { + expect( + await capturedModeEnabled(band: '6GHz', selectedMode: 'None'), + 'OWE', + ); + }); + + test('6 GHz + WPA2-Personal selected → forced to WPA3-Personal', () async { + expect( + await capturedModeEnabled(band: '6GHz', selectedMode: 'WPA2-Personal'), + 'WPA3-Personal', + ); + }); + + test('5 GHz + None selected → written verbatim (no 6 GHz override)', + () async { + expect( + await capturedModeEnabled(band: '5GHz', selectedMode: 'None'), + 'None', + ); + }); }); // ------------------------------------------------------------------------- @@ -993,7 +1225,8 @@ void main() { return keys; } - test('skips AP write when only guest enabled toggled', () async { + test('writes AP.Enable but not Security when only enabled toggled', + () async { // Guest group: only `enabled` changed. No SSID-name change, no AP change. final guestAgg = WifiQuickSetupNetwork( isGuest: true, @@ -1038,7 +1271,10 @@ void main() { final keys = capturedKeys(); // SSID enable write happens… expect(keys, contains('Device.WiFi.SSID.3.Enable')); - // …but AP layer (KeyPassphrase / Security.ModeEnabled) must NOT be touched. + // …and AP.Enable is mirrored so the AP actually stops broadcasting (#972)… + expect(keys, contains('Device.WiFi.AccessPoint.3.Enable')); + // …but the AP security layer (KeyPassphrase / Security.*) must NOT be + // touched when only the enabled flag changed. expect( keys.any((k) => k.startsWith('Device.WiFi.AccessPoint.3.Security.')), isFalse, @@ -1146,4 +1382,163 @@ void main() { expect(keys.any((k) => k.contains('Security.KeyPassphrase')), isFalse); }); }); + + // ------------------------------------------------------------------------- + // 6 GHz security override (_securityModeFor6GHz) — issue #1073 + // + // Wi-Fi 6E mandates WPA3, so on 6 GHz the service overrides the selected + // mode: open modes ('None' / 'OWE' / '') → 'OWE' (the TR-181 token firmware + // accepts for Enhanced Open), everything else → 'WPA3-Personal'. On other + // bands the selected mode is written verbatim. These tests assert the exact + // ModeEnabled value sent to firmware, guarding against a regression to the + // old invalid 'Enhanced-Open' token. + // ------------------------------------------------------------------------- + + group('saveQuickSetup — 6 GHz security override (#1073)', () { + late MockUspClient mockUsp; + late UspWifiSettingsService writeSvc; + + setUp(() { + mockUsp = MockUspClient(); + writeSvc = UspWifiSettingsService(mockUsp); + when(() => mockUsp.set(any(), allowPartial: any(named: 'allowPartial'))) + .thenAnswer((_) async => uspSuccess()); + }); + + WifiNetworkUIModel makeNetwork({ + required String band, + String ssidInstancePath = 'Device.WiFi.SSID.1.', + String accessPointInstancePath = 'Device.WiFi.AccessPoint.1.', + }) => + WifiNetworkUIModel( + ssidInstancePath: ssidInstancePath, + accessPointInstancePath: accessPointInstancePath, + radioInstancePath: 'Device.WiFi.Radio.1.', + ssid: 'Home', + enabled: true, + ssidAdvertisementEnabled: true, + supportedSecurityModes: const [ + 'None', + 'WPA2-Personal', + 'WPA3-Personal', + 'OWE' + ], + securityMode: 'WPA2-Personal', + keyPassphrase: '', + isGuest: false, + band: band, + channel: 6, + channelBandwidth: '20MHz', + autoChannelEnable: true, + possibleChannels: const [1, 6, 11], + operatingStandards: 'ax', + supportedStandards: 'ax', + ); + + /// Returns the value written to `Security.ModeEnabled`, or null if the + /// param was never sent. + Future capturedModeEnabled({ + required String band, + required String selectedMode, + String ap = 'Device.WiFi.AccessPoint.1.', + }) async { + final agg = WifiQuickSetupNetwork( + isGuest: false, + ssid: 'Home', + securityMode: 'WPA2-Personal', + keyPassphrase: '', + supportedSecurityModes: const [ + 'None', + 'WPA2-Personal', + 'WPA3-Personal', + 'OWE' + ], + ssidInstancePaths: const ['Device.WiFi.SSID.1.'], + apInstancePaths: [ap], + ); + // Baseline mode differs from every selectedMode under test so the + // security diff always fires (otherwise WPA2-in == WPA2-baseline would + // be a no-op and nothing would be sent). + const orig = WifiQuickSetupSettings( + isGuest: false, + enabled: true, + ssid: 'Home', + password: '', + securityMode: 'WPA3-Personal-Transition', + supportedSecurityModes: [ + 'None', + 'WPA2-Personal', + 'WPA3-Personal', + 'OWE' + ], + ); + + final original = WifiSettingsSettings( + networks: [ + makeNetwork(band: band, accessPointInstancePath: ap) + .copyWith(securityMode: 'WPA3-Personal-Transition') + ], + quickSetupEnabled: true, + quickSetupMain: orig, + ); + final current = original.copyWith( + quickSetupMain: orig.copyWith(securityMode: selectedMode), + ); + final status = WifiSettingsStatus(quickSetupMainAggregate: agg); + + await writeSvc.saveQuickSetup( + original: original, + current: current, + status: status, + ); + + final captured = verify(() => mockUsp.set(captureAny(), + allowPartial: any(named: 'allowPartial'))).captured; + for (final arg in captured) { + if (arg is Map && arg.containsKey('${ap}Security.ModeEnabled')) { + return arg['${ap}Security.ModeEnabled'] as String?; + } + } + return null; + } + + test('6 GHz + OWE selected → sends OWE verbatim', () async { + expect( + await capturedModeEnabled(band: '6GHz', selectedMode: 'OWE'), + 'OWE', + ); + }); + + test('6 GHz + None (open) selected → normalized to OWE', () async { + expect( + await capturedModeEnabled(band: '6GHz', selectedMode: 'None'), + 'OWE', + ); + }); + + test('6 GHz + WPA2-Personal selected → forced to WPA3-Personal', () async { + // Pass a non-WPA3 mode so this genuinely exercises the coercion branch + // (WPA3 input would pass even if the override were removed). + expect( + await capturedModeEnabled(band: '6GHz', selectedMode: 'WPA2-Personal'), + 'WPA3-Personal', + ); + }); + + test('2.4 GHz + OWE selected → written verbatim (no 6 GHz override)', + () async { + expect( + await capturedModeEnabled(band: '2.4GHz', selectedMode: 'OWE'), + 'OWE', + ); + }); + + test('5 GHz + None selected → written verbatim (no 6 GHz override)', + () async { + expect( + await capturedModeEnabled(band: '5GHz', selectedMode: 'None'), + 'None', + ); + }); + }); } diff --git a/test/providers/auth/auth_notifier_test.dart b/test/providers/auth/auth_notifier_test.dart index 9a35b6757..140133795 100644 --- a/test/providers/auth/auth_notifier_test.dart +++ b/test/providers/auth/auth_notifier_test.dart @@ -1,4 +1,3 @@ -import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; @@ -15,7 +14,6 @@ import 'package:privacy_gui/core/usp/services/sse_manager.dart'; import 'package:privacy_gui/core/usp/services/sse_subscription_registry.dart'; import 'package:privacy_gui/core/usp/services/usp_client.dart'; import 'package:privacy_gui/providers/auth/auth_provider.dart'; -import 'package:privacy_gui/providers/auth/auth_result.dart'; import 'package:privacy_gui/providers/auth/auth_service.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -58,13 +56,6 @@ void main() { setUp(() { SharedPreferences.setMockInitialValues({}); - // Mock FlutterSecureStorage platform channel used by hardcoded - // `const FlutterSecureStorage()` in localLogin. - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler( - const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'), - (call) async => null, - ); mockAuthService = MockAuthService(); mockUspCoordinator = MockUspAuthCoordinator(); mockSseManager = MockSseManager(); @@ -80,13 +71,14 @@ void main() { when(() => mockUspCoordinator.restoreSession()).thenAnswer((_) async {}); when(() => mockFingerprint.clear()).thenAnswer((_) async {}); when(() => mockFingerprint.store(any())).thenAnswer((_) async {}); - when(() => mockAuthService.clearAllCredentials()) - .thenAnswer((_) async => const AuthSuccess(null)); + when(() => mockAuthService.clearAllCredentials()).thenAnswer((_) async {}); when(() => mockSessionService.fetchDeviceInfoAndInitializeServices()) .thenAnswer((_) async => _testDeviceInfo); + when(() => mockUspClient.isAuthenticated).thenReturn(false); }); - ProviderContainer createContainer() { + ProviderContainer createContainer({bool isAuthenticated = false}) { + when(() => mockUspClient.isAuthenticated).thenReturn(isAuthenticated); return ProviderContainer( overrides: [ authServiceProvider.overrideWithValue(mockAuthService), @@ -110,7 +102,6 @@ void main() { final state = await container.read(authProvider.future); expect(state.loginType, LoginType.none); - expect(state.localPassword, isNull); container.dispose(); }); }); @@ -120,13 +111,9 @@ void main() { // --------------------------------------------------------------------------- group('AuthNotifier — init', () { - test('restores local login type and password', () async { - when(() => mockAuthService.getStoredLoginType()) - .thenAnswer((_) async => AuthSuccess(LoginType.local)); - when(() => mockAuthService.getStoredLocalPassword()) - .thenAnswer((_) async => AuthSuccess('storedPass')); - - final container = createContainer(); + test('restores session and sets LoginType.local when authenticated', + () async { + final container = createContainer(isAuthenticated: true); container.read(authProvider); // trigger build await Future.delayed(Duration.zero); @@ -135,36 +122,12 @@ void main() { final state = container.read(authProvider).value; expect(state?.loginType, LoginType.local); - expect(state?.localPassword, 'storedPass'); verify(() => mockUspCoordinator.restoreSession()).called(1); container.dispose(); }); - test('does not restore USP session for non-local login', () async { - when(() => mockAuthService.getStoredLoginType()) - .thenAnswer((_) async => AuthSuccess(LoginType.none)); - when(() => mockAuthService.getStoredLocalPassword()) - .thenAnswer((_) async => AuthSuccess(null)); - - final container = createContainer(); - container.read(authProvider); - await Future.delayed(Duration.zero); - - final notifier = container.read(authProvider.notifier); - await notifier.init(); - - verifyNever(() => mockUspCoordinator.restoreSession()); - container.dispose(); - }); - - test('handles credential retrieval failure gracefully', () async { - when(() => mockAuthService.getStoredLoginType()).thenAnswer((_) async => - AuthFailure(StorageError(originalError: Exception('storage error')))); - when(() => mockAuthService.getStoredLocalPassword()).thenAnswer( - (_) async => AuthFailure( - StorageError(originalError: Exception('storage error')))); - - final container = createContainer(); + test('sets LoginType.none when not authenticated', () async { + final container = createContainer(isAuthenticated: false); container.read(authProvider); await Future.delayed(Duration.zero); @@ -173,29 +136,21 @@ void main() { final state = container.read(authProvider).value; expect(state?.loginType, LoginType.none); - expect(state?.localPassword, isNull); container.dispose(); }); test('concurrent init calls are coalesced — restoreSession called once', () async { - when(() => mockAuthService.getStoredLoginType()) - .thenAnswer((_) async => AuthSuccess(LoginType.local)); - when(() => mockAuthService.getStoredLocalPassword()) - .thenAnswer((_) async => AuthSuccess('storedPass')); // Slow restoreSession to ensure concurrent calls overlap when(() => mockUspCoordinator.restoreSession()) .thenAnswer((_) => Future.delayed(const Duration(milliseconds: 50))); - final container = createContainer(); + final container = createContainer(isAuthenticated: true); container.read(authProvider); await Future.delayed(Duration.zero); final notifier = container.read(authProvider.notifier); - // Launch multiple concurrent init calls. - // Dart's single-threaded event loop guarantees the first init() acquires - // _initInProgress before the others check it (no await before the guard). final futures = [ notifier.init(), notifier.init(), @@ -207,59 +162,6 @@ void main() { verify(() => mockUspCoordinator.restoreSession()).called(1); container.dispose(); }); - - test('concurrent init waiters receive error via completeError', () async { - var callCount = 0; - // First call succeeds slowly, subsequent calls will wait on Completer - // Then we make it throw to trigger completeError path - when(() => mockAuthService.getStoredLoginType()).thenAnswer((_) async { - callCount++; - await Future.delayed(const Duration(milliseconds: 50)); - throw StateError('Simulated storage failure'); - }); - - final container = createContainer(); - container.read(authProvider); - await Future.delayed(Duration.zero); - - final notifier = container.read(authProvider.notifier); - - // Track results/errors received by each caller. - // Dart's single-threaded event loop guarantees the first init() acquires - // _initInProgress before the others check it (no await before the guard). - AuthState? result1; - Object? error2, error3; - - await Future.wait([ - // Primary caller: receives null (init never throws) - notifier.init().then((r) => result1 = r), - // Concurrent waiters: receive error via completeError - notifier.init().catchError((e) { - error2 = e; - return null; - }), - notifier.init().catchError((e) { - error3 = e; - return null; - }), - ]); - - // getStoredLoginType should only be called once (coalescing works) - expect(callCount, 1); - - // Primary caller receives null (init never throws — callers use bare .then) - expect(result1, isNull); - - // Concurrent waiters receive error via completeError - expect(error2, isA()); - expect(error3, isA()); - - // State should be AsyncError - final state = container.read(authProvider); - expect(state.hasError, isTrue); - - container.dispose(); - }); }); // --------------------------------------------------------------------------- @@ -267,8 +169,7 @@ void main() { // --------------------------------------------------------------------------- group('AuthNotifier — localLogin', () { - test('successful login sets state with password and LoginType.local', - () async { + test('successful login sets state with LoginType.local', () async { when(() => mockUspCoordinator.tryUspLogin('pass123')) .thenAnswer((_) async => true); @@ -281,7 +182,6 @@ void main() { final state = container.read(authProvider).value; expect(state?.loginType, LoginType.local); - expect(state?.localPassword, 'pass123'); container.dispose(); }); @@ -339,8 +239,6 @@ void main() { 'account-locked error is passed through to the view as ' 'errorAdminAccountLocked (not overwritten to errorUnexpected)', () async { - // The coordinator surfaces account-locked as an UnexpectedError carrying - // the error-code identifier in `detail`. _mapToViewError must keep it. when(() => mockUspCoordinator.tryUspLogin('locked')).thenThrow( UnexpectedError( originalError: Exception('Account is locked'), @@ -425,7 +323,6 @@ void main() { final state = container.read(authProvider).value; expect(state?.loginType, LoginType.none); - expect(state?.localPassword, isNull); container.dispose(); }); diff --git a/test/providers/auth/auth_service_test.dart b/test/providers/auth/auth_service_test.dart index b09278703..d5844cf7d 100644 --- a/test/providers/auth/auth_service_test.dart +++ b/test/providers/auth/auth_service_test.dart @@ -1,163 +1,18 @@ -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:privacy_gui/core/errors/service_error.dart'; import 'package:privacy_gui/providers/auth/auth_service.dart'; -import 'package:privacy_gui/providers/auth/auth_types.dart'; - -class MockSecureStorage extends Mock implements FlutterSecureStorage {} void main() { - late MockSecureStorage mockStorage; late AuthService service; setUp(() { - mockStorage = MockSecureStorage(); - service = AuthService(mockStorage); - }); - - // --------------------------------------------------------------------------- - // getStoredLocalPassword - // --------------------------------------------------------------------------- - - group('AuthService — getStoredLocalPassword', () { - test('returns stored password when present', () async { - when(() => mockStorage.read(key: any(named: 'key'))) - .thenAnswer((_) async => 'myPassword'); - - final result = await service.getStoredLocalPassword(); - - expect(result.isSuccess, isTrue); - result.when( - success: (value) => expect(value, 'myPassword'), - failure: (_) => fail('expected success'), - ); - }); - - test('returns null when no password stored', () async { - when(() => mockStorage.read(key: any(named: 'key'))) - .thenAnswer((_) async => null); - - final result = await service.getStoredLocalPassword(); - - expect(result.isSuccess, isTrue); - result.when( - success: (value) => expect(value, isNull), - failure: (_) => fail('expected success'), - ); - }); - - test('returns failure on storage exception', () async { - when(() => mockStorage.read(key: any(named: 'key'))) - .thenThrow(Exception('disk error')); - - final result = await service.getStoredLocalPassword(); - - expect(result.isFailure, isTrue); - result.when( - success: (_) => fail('expected failure'), - failure: (error) => expect(error, isA()), - ); - }); - }); - - // --------------------------------------------------------------------------- - // getStoredLoginType - // --------------------------------------------------------------------------- - - group('AuthService — getStoredLoginType', () { - test('returns LoginType.local when password exists', () async { - when(() => mockStorage.read(key: any(named: 'key'))) - .thenAnswer((_) async => 'somePass'); - - final result = await service.getStoredLoginType(); - - expect(result.isSuccess, isTrue); - result.when( - success: (value) => expect(value, LoginType.local), - failure: (_) => fail('expected success'), - ); - }); - - test('returns LoginType.none when no password', () async { - when(() => mockStorage.read(key: any(named: 'key'))) - .thenAnswer((_) async => null); - - final result = await service.getStoredLoginType(); - - expect(result.isSuccess, isTrue); - result.when( - success: (value) => expect(value, LoginType.none), - failure: (_) => fail('expected success'), - ); - }); - - test('returns failure on storage exception', () async { - when(() => mockStorage.read(key: any(named: 'key'))) - .thenThrow(Exception('corrupted')); - - final result = await service.getStoredLoginType(); - - expect(result.isFailure, isTrue); - }); + service = AuthService(); }); - // --------------------------------------------------------------------------- - // saveLocalPassword - // --------------------------------------------------------------------------- - - group('AuthService — saveLocalPassword', () { - test('writes password to secure storage', () async { - when(() => mockStorage.write( - key: any(named: 'key'), - value: any(named: 'value'), - )).thenAnswer((_) async {}); - - final result = await service.saveLocalPassword('newPass'); - - expect(result.isSuccess, isTrue); - verify(() => mockStorage.write(key: 'LocalPassword', value: 'newPass')) - .called(1); - }); - - test('returns failure on storage exception', () async { - when(() => mockStorage.write( - key: any(named: 'key'), - value: any(named: 'value'), - )).thenThrow(Exception('write fail')); - - final result = await service.saveLocalPassword('pass'); - - expect(result.isFailure, isTrue); - result.when( - success: (_) => fail('expected failure'), - failure: (error) => expect(error, isA()), - ); - }); - }); - - // --------------------------------------------------------------------------- - // clearAllCredentials - // --------------------------------------------------------------------------- - group('AuthService — clearAllCredentials', () { - test('deletes password from secure storage', () async { - when(() => mockStorage.delete(key: any(named: 'key'))) - .thenAnswer((_) async {}); - - final result = await service.clearAllCredentials(); - - expect(result.isSuccess, isTrue); - verify(() => mockStorage.delete(key: 'LocalPassword')).called(1); - }); - - test('returns failure on storage exception', () async { - when(() => mockStorage.delete(key: any(named: 'key'))) - .thenThrow(Exception('delete fail')); - - final result = await service.clearAllCredentials(); - - expect(result.isFailure, isTrue); + test('completes without error (no-op)', () async { + // clearAllCredentials is now a no-op since token storage + // is handled by UspAuthCoordinator + await expectLater(service.clearAllCredentials(), completes); }); }); } diff --git a/test/providers/auth/auth_state_test.dart b/test/providers/auth/auth_state_test.dart index a9392cf7f..a414fb104 100644 --- a/test/providers/auth/auth_state_test.dart +++ b/test/providers/auth/auth_state_test.dart @@ -7,41 +7,35 @@ void main() { test('empty() creates state with LoginType.none and null fields', () { final state = AuthState.empty(); expect(state.loginType, LoginType.none); - expect(state.localPassword, isNull); expect(state.localPasswordHint, isNull); }); test('copyWith replaces specified fields', () { final state = AuthState.empty(); final updated = state.copyWith( - localPassword: 'secret', + localPasswordHint: 'hint', loginType: LoginType.local, ); - expect(updated.localPassword, 'secret'); + expect(updated.localPasswordHint, 'hint'); expect(updated.loginType, LoginType.local); - expect(updated.localPasswordHint, isNull); }); test('copyWith preserves unspecified fields', () { final state = AuthState( - localPassword: 'pass', localPasswordHint: 'hint', loginType: LoginType.local, ); - final updated = state.copyWith(localPassword: 'newpass'); - expect(updated.localPassword, 'newpass'); + final updated = state.copyWith(loginType: LoginType.remote); expect(updated.localPasswordHint, 'hint'); - expect(updated.loginType, LoginType.local); + expect(updated.loginType, LoginType.remote); }); test('fromJson parses valid JSON', () { final json = { - 'localPassword': 'admin', 'localPasswordHint': 'my hint', 'loginType': 'local', }; final state = AuthState.fromJson(json); - expect(state.localPassword, 'admin'); expect(state.localPasswordHint, 'my hint'); expect(state.loginType, LoginType.local); }); @@ -53,21 +47,35 @@ void main() { }); test('fromJson defaults to LoginType.none for missing type', () { - final state = AuthState.fromJson({}); + final state = AuthState.fromJson(const {}); expect(state.loginType, LoginType.none); - expect(state.localPassword, isNull); }); test('equality: identical states are equal', () { - final a = AuthState(localPassword: 'x', loginType: LoginType.local); - final b = AuthState(localPassword: 'x', loginType: LoginType.local); + final a = AuthState(localPasswordHint: 'x', loginType: LoginType.local); + final b = AuthState(localPasswordHint: 'x', loginType: LoginType.local); expect(a, b); }); test('equality: different states are not equal', () { - final a = AuthState(localPassword: 'x', loginType: LoginType.local); - final b = AuthState(localPassword: 'y', loginType: LoginType.local); + final a = AuthState(localPasswordHint: 'x', loginType: LoginType.local); + final b = AuthState(localPasswordHint: 'y', loginType: LoginType.local); expect(a, isNot(b)); }); + + test('isLoggedIn is true for local and remote, false for none', () { + expect(const AuthState(loginType: LoginType.none).isLoggedIn, isFalse); + expect(const AuthState(loginType: LoginType.local).isLoggedIn, isTrue); + expect(const AuthState(loginType: LoginType.remote).isLoggedIn, isTrue); + }); + + test('isRemoteAssistance is true only for LoginType.remote', () { + expect(const AuthState(loginType: LoginType.none).isRemoteAssistance, + isFalse); + expect(const AuthState(loginType: LoginType.local).isRemoteAssistance, + isFalse); + expect(const AuthState(loginType: LoginType.remote).isRemoteAssistance, + isTrue); + }); }); } diff --git a/tools/font_subset/.gitignore b/tools/font_subset/.gitignore new file mode 100644 index 000000000..35fd13303 --- /dev/null +++ b/tools/font_subset/.gitignore @@ -0,0 +1,4 @@ +# Reproducible intermediates — regenerated by regenerate.sh, not checked in. +.venv/ +full_fonts/ +out/ diff --git a/tools/font_subset/README.md b/tools/font_subset/README.md new file mode 100644 index 000000000..b11b3e59b --- /dev/null +++ b/tools/font_subset/README.md @@ -0,0 +1,77 @@ +# Fallback font subsetting (offline CJK) + +Build-time tool that generates the bundled **CJK subset fonts** used for +offline rendering. The app must work with **no network** (router firmware), so +every glyph the interface can show must ship in the product. Full Noto Sans CJK +is ~12.7 MB; subsetting to just the glyphs the interface uses brings the 5 CJK +fonts down to **~2.1 MB (84% smaller)** while keeping every language's glyph +shapes correct. + +Architecture overview and rationale: +[raw/offline_font_bundle_size_options.md](../../../Documents/docs/raw/offline_font_bundle_size_options.md) +(Obsidian vault) — or ask; it documents the full A+ design. + +## ⚠️ When you MUST re-run this + +Re-run **`regenerate.sh`** after ANY change that can introduce a new CJK / kana / +hangul glyph into interface text: + +- new or edited strings in `lib/l10n/app_{zh,zh_TW,ja,ko}.arb` +- a new language name in `lib/util/languages.dart` +- a hardcoded CJK literal in Dart source under `lib/` + +**If you skip it, the subset silently misses the new glyph** → online it falls +back to the CDN (a network request), **offline it renders as tofu (□)**. This is +hard to spot because most text still looks fine. + +> Only the **5 CJK subsets** are regenerated. The non-CJK fallbacks +> (`NotoSansThai`, `NotoSansArabic`, `NotoSans-Latin`, `Roboto` in +> `assets/fonts/fallback/`) are FULL fonts that never change — leave them. + +## Usage + +```bash +bash tools/font_subset/regenerate.sh +``` + +One idempotent command: sets up a venv, downloads the full Noto Sans CJK OTFs +(first run only, needs net), extracts the interface charset, subsets the 5 CJK +fonts, and deploys them to `assets/fonts/fallback/`. + +Then rebuild and verify no CDN requests appear for CJK text: + +```bash +flutter build web --debug +# serve build/web, open a CJK locale, DevTools → Network → Font: +# should load only assets/fonts/fallback/*.woff2, zero fonts.gstatic.com +``` + +Optional visual check of glyph correctness: + +```bash +.venv/bin/python tools/font_subset/make_test_page.py # -> out/test_render.html +``` + +## Charset sources (extract_charset.py) + +The interface charset is the union of: +1. translatable values in the CJK ARB files (skips `@` metadata + ICU placeholders) +2. full CJK punctuation / fullwidth / compat blocks (U+3000–303F, U+FF00–FF60, U+FE30–FE4F) +3. language picker native names in `lib/util/languages.dart` +4. hardcoded CJK literals in Dart source under `lib/` (excludes generated l10n) + +Missing any of these classes was a real cause of stray CDN requests — keep all four. + +## Files + +- `regenerate.sh` — the one command to run (download → extract → subset → deploy). +- `extract_charset.py` — builds `out/charset.txt` from the sources above. +- `make_test_page.py` — renders sample strings per language to `out/test_render.html`. +- `.venv/`, `full_fonts/`, `out/` — reproducible intermediates, gitignored. + +## Where the fonts are consumed + +- Declared eager under `pubspec.yaml` `fonts:` as `packages/ui_kit_library/NotoSans*` + (registered before first frame — this is what keeps CJK off the CDN). +- Locale→family mapping: `lib/localization/fallback_font_resolver.dart` (single + source of truth), injected into ui_kit's `LocaleFallbackFont` at startup. diff --git a/tools/font_subset/extract_charset.py b/tools/font_subset/extract_charset.py new file mode 100644 index 000000000..c4325f849 --- /dev/null +++ b/tools/font_subset/extract_charset.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Extract the set of characters actually used by the app's interface strings. + +Reads the CJK-relevant ARB files, collects every character that appears in a +translatable *value* (skipping `@`-prefixed metadata keys and ICU placeholder +names), and writes them to a UTF-8 charset file for pyftsubset --text-file. + +Scope note: this covers the FIXED interface strings only. User-typed content +(SSIDs, device names) is NOT covered here — that is handled at runtime by the +CDN on-demand fallback (see doc/theme/offline_font_bundle_size_options.md §A+). +""" +import json +import re +import sys +from pathlib import Path + +# ARB files whose glyphs need CJK coverage. All 26 locales share the same keys, +# but only these carry CJK/kana/hangul glyphs in their values. +ARB_FILES = ["app_zh.arb", "app_zh_TW.arb", "app_ja.arb", "app_ko.arb"] + +# ICU placeholder tokens like {count}, {deviceName} — strip so we don't count +# the ASCII inside braces as "content" (harmless, but keeps intent clear). +PLACEHOLDER_RE = re.compile(r"\{[^{}]*\}") + + +def extract_values(arb_path: Path) -> list[str]: + data = json.loads(arb_path.read_text(encoding="utf-8")) + values = [] + for key, val in data.items(): + if key.startswith("@"): # metadata object or @@locale + continue + if not isinstance(val, str): + continue + values.append(PLACEHOLDER_RE.sub("", val)) + return values + + +def main() -> int: + l10n_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("lib/l10n") + out_path = Path(sys.argv[2]) if len(sys.argv) > 2 else Path( + "tools/font_subset/out/charset.txt" + ) + + chars: set[str] = set() + per_file: dict[str, int] = {} + for name in ARB_FILES: + p = l10n_dir / name + if not p.exists(): + print(f"WARN: {p} not found, skipping", file=sys.stderr) + continue + before = len(chars) + for v in extract_values(p): + chars.update(v) + # count only CJK-ish additions for reporting + per_file[name] = len(chars) - before + + # (a) Language picker native names (lib/util/languages.dart): the picker + # lists ALL languages' own names at once (简体中文, 日本語, 한국어, ไทย …), + # so every locale's subset must contain these regardless of active locale. + langs = Path("lib/util/languages.dart") + if langs.exists(): + for m in re.findall(r"'name':\s*'([^']*)'", langs.read_text(encoding="utf-8")): + chars.update(m) + + # (b) Hardcoded CJK string literals in Dart source (log/debug/labels not in + # ARB). Scan lib/ excluding the generated l10n (already covered via ARB). + import glob + for f in glob.glob("lib/**/*.dart", recursive=True): + if "/l10n/gen/" in f: + continue + txt = Path(f).read_text(encoding="utf-8", errors="ignore") + for ch in txt: + o = ord(ch) + if 0x4E00 <= o <= 0x9FFF or 0x3040 <= o <= 0x30FF or 0xAC00 <= o <= 0xD7AF: + chars.add(ch) + + # Always include ASCII printable + full CJK punctuation / fullwidth / + # symbol blocks. The interface strings only reference a handful of these, + # but the layout engine probes the whole range; anything missing falls + # through to the CDN (fails offline). These blocks are tiny (~150 glyphs, + # <1KB in the subset) so include them wholesale to avoid CDN dependency. + for cp in range(0x20, 0x7F): # ASCII printable + chars.add(chr(cp)) + for cp in range(0x3000, 0x3040): # CJK symbols & punctuation + chars.add(chr(cp)) + for cp in range(0xFF00, 0xFF61): # Fullwidth forms (punct, digits, latin) + chars.add(chr(cp)) + for cp in range(0xFE30, 0xFE50): # CJK compatibility forms (vertical punct) + chars.add(chr(cp)) + chars.update(" 、。「」『』():;?!…—~·・﹅﹆※") # common extras + + # Report CJK/kana/hangul count specifically (the expensive glyphs). + def is_cjk(ch: str) -> bool: + o = ord(ch) + return ( + 0x4E00 <= o <= 0x9FFF # CJK Unified + or 0x3400 <= o <= 0x4DBF # CJK Ext A + or 0x3040 <= o <= 0x30FF # Hiragana/Katakana + or 0xAC00 <= o <= 0xD7AF # Hangul syllables + ) + + cjk_count = sum(1 for c in chars if is_cjk(c)) + + out_path.parent.mkdir(parents=True, exist_ok=True) + # Sort for deterministic output (stable across runs → reproducible subset). + out_path.write_text("".join(sorted(chars)), encoding="utf-8") + + print(f"Total unique chars written : {len(chars)}") + print(f" of which CJK/kana/hangul : {cjk_count}") + print(f"Charset written to : {out_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/font_subset/make_test_page.py b/tools/font_subset/make_test_page.py new file mode 100644 index 000000000..ec8ab7966 --- /dev/null +++ b/tools/font_subset/make_test_page.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Generate a self-contained HTML page that renders real interface strings using +the subset woff2 fonts, so a human can eyeball glyph correctness / missing chars. + +Output: out/test_render.html (open in a browser) +Each language block uses its own subset font (approach ①, glyph-correct). +""" +import base64 +import json +import re +from pathlib import Path + +PH = re.compile(r"\{[^{}]*\}") +OUT = Path(__file__).parent / "out" + +# (label, arb file, subset woff2, how many sample strings) +LANGS = [ + ("简体中文 (zh)", "app_zh.arb", "NotoSansCJKsc.subset.woff2"), + ("繁體中文 (zh_TW)", "app_zh_TW.arb", "NotoSansCJKtc.subset.woff2"), + ("日本語 (ja)", "app_ja.arb", "NotoSansCJKjp.subset.woff2"), + ("한국어 (ko)", "app_ko.arb", "NotoSansCJKkr.subset.woff2"), +] +L10N = Path("lib/l10n") + + +def sample_strings(arb: Path, n: int = 25) -> list[str]: + d = json.loads(arb.read_text(encoding="utf-8")) + out = [] + for k, v in d.items(): + if k.startswith("@") or not isinstance(v, str): + continue + s = PH.sub("…", v).strip() + if s and any(ord(c) >= 0x2E80 for c in s): # has CJK + out.append(s) + if len(out) >= n: + break + return out + + +def b64_font(path: Path) -> str: + return base64.b64encode(path.read_bytes()).decode("ascii") + + +def main() -> int: + blocks = [] + faces = [] + for i, (label, arb_name, woff_name) in enumerate(LANGS): + woff = OUT / woff_name + if not woff.exists(): + print(f"WARN: {woff} missing, run subset_fonts.sh first") + continue + fam = f"Subset{i}" + faces.append( + f"@font-face{{font-family:'{fam}';" + f"src:url(data:font/woff2;base64,{b64_font(woff)}) format('woff2');}}" + ) + samples = sample_strings(L10N / arb_name) + size_kb = woff.stat().st_size / 1024 + rows = "".join(f"
  • {s}
  • " for s in samples) + blocks.append( + f"
    " + f"

    {label} — {woff_name} ({size_kb:.0f} KB)

    " + f"
      {rows}
    " + ) + + html = ( + "" + "CJK Subset Render Test" + "

    CJK Subset 渲染驗證

    " + "

    每區塊用該語言的 subset 字體渲染真實介面字串。" + "檢查:① 有無豆腐字 □(缺字)② 字形是否符合該語言標準(日文漢字不應中國化等)。

    " + + "".join(blocks) + + "" + ) + out_path = OUT / "test_render.html" + out_path.write_text(html, encoding="utf-8") + print(f"Wrote {out_path} ({out_path.stat().st_size/1024:.0f} KB)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/font_subset/regenerate.sh b/tools/font_subset/regenerate.sh new file mode 100755 index 000000000..c2f12c908 --- /dev/null +++ b/tools/font_subset/regenerate.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# Regenerate the bundled CJK subset fonts from the current interface charset and +# deploy them into the app (assets/fonts/fallback/). +# +# WHEN TO RUN: after ANY change to interface text that adds new CJK/kana/hangul +# glyphs — new/edited ARB strings (lib/l10n/app_{zh,zh_TW,ja,ko}.arb), new +# language names in lib/util/languages.dart, or hardcoded CJK literals in Dart +# source. If you skip this, the subset silently misses the new glyphs: online +# they fall back to the CDN, OFFLINE they render as tofu (□). +# +# One command, idempotent. Only the 5 CJK subsets are (re)generated; the +# non-CJK fallbacks (Thai/Arabic/Latin/Roboto) are FULL fonts that never change +# and are already committed under assets/fonts/fallback/. +# +# Prereq: python3. Downloads full Noto Sans CJK OTFs on first run (needs net). +set -euo pipefail + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$DIR/../.." && pwd)" +VENV="$DIR/.venv" +FULL="$DIR/full_fonts" +OUT="$DIR/out" +DEPLOY="$ROOT/assets/fonts/fallback" + +# 1. venv + fonttools +if [ ! -x "$VENV/bin/pyftsubset" ]; then + echo "[1/5] creating venv + installing fonttools/brotli..." + python3 -m venv "$VENV" + "$VENV/bin/pip" install --quiet --upgrade pip fonttools brotli +else + echo "[1/5] venv ready" +fi + +# 2. download full Noto Sans CJK OTFs (subset source; production woff2 chunks +# cannot be re-subsetted, the full fonts are required) +mkdir -p "$FULL" +echo "[2/5] ensuring full Noto Sans CJK OTFs..." +"$VENV/bin/python" - "$FULL" <<'PY' +import os, ssl, sys, urllib.request +outdir = sys.argv[1] +base = "https://github.com/notofonts/noto-cjk/raw/main/Sans/OTF" +targets = { + "sc": "SimplifiedChinese/NotoSansCJKsc-Regular.otf", + "tc": "TraditionalChinese/NotoSansCJKtc-Regular.otf", + "hk": "TraditionalChineseHK/NotoSansCJKhk-Regular.otf", + "jp": "Japanese/NotoSansCJKjp-Regular.otf", + "kr": "Korean/NotoSansCJKkr-Regular.otf", +} +ctx = ssl.create_default_context() +for tag, path in targets.items(): + out = f"{outdir}/NotoSansCJK{tag}.otf" + if os.path.exists(out) and os.path.getsize(out) > 1_000_000: + print(f" {tag}: cached"); continue + req = urllib.request.Request(f"{base}/{path}", headers={"User-Agent": "Mozilla/5.0"}) + with urllib.request.urlopen(req, timeout=180, context=ctx) as r, open(out, "wb") as f: + f.write(r.read()) + print(f" {tag}: downloaded {os.path.getsize(out):,} bytes") +PY + +# 3. extract the interface charset +echo "[3/5] extracting interface charset..." +( cd "$ROOT" && "$VENV/bin/python" "$DIR/extract_charset.py" ) + +# 4. subset the 5 CJK fonts to woff2 +echo "[4/5] subsetting..." +mkdir -p "$OUT" +for tag in sc tc hk jp kr; do + "$VENV/bin/pyftsubset" "$FULL/NotoSansCJK${tag}.otf" \ + --text-file="$OUT/charset.txt" \ + --flavor=woff2 --layout-features='*' --no-hinting --desubroutinize \ + --output-file="$OUT/NotoSansCJK${tag}.subset.woff2" +done + +# 5. deploy into the app +echo "[5/5] deploying to $DEPLOY..." +cp "$OUT"/NotoSansCJK{sc,tc,hk,jp,kr}.subset.woff2 "$DEPLOY/" +total=0 +for tag in sc tc hk jp kr; do + sz=$(stat -f%z "$DEPLOY/NotoSansCJK${tag}.subset.woff2") + total=$((total + sz)) +done +printf "done. 5 CJK subsets = %.2f MB deployed.\n" "$(python3 -c "print($total/1024/1024)")" +echo "Remember to rebuild the web app and verify (see README)." diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.0.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.0.woff2 deleted file mode 100644 index 060ded89c..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.0.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.1.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.1.woff2 deleted file mode 100644 index cdfd06f11..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.1.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.10.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.10.woff2 deleted file mode 100644 index a92421609..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.10.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.100.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.100.woff2 deleted file mode 100644 index d16bc7f70..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.100.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.101.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.101.woff2 deleted file mode 100644 index 4cb762803..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.101.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.102.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.102.woff2 deleted file mode 100644 index dc398b253..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.102.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.103.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.103.woff2 deleted file mode 100644 index 3664069eb..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.103.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.104.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.104.woff2 deleted file mode 100644 index 28e378457..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.104.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.105.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.105.woff2 deleted file mode 100644 index 8cca3b350..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.105.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.106.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.106.woff2 deleted file mode 100644 index 4e20d45b8..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.106.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.107.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.107.woff2 deleted file mode 100644 index 751cbd3ed..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.107.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.108.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.108.woff2 deleted file mode 100644 index b11eb051d..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.108.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.109.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.109.woff2 deleted file mode 100644 index c55e073fe..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.109.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.110.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.110.woff2 deleted file mode 100644 index 2be637e7c..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.110.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.111.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.111.woff2 deleted file mode 100644 index 6a0a1da35..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.111.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.112.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.112.woff2 deleted file mode 100644 index cdf2f7b71..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.112.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.113.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.113.woff2 deleted file mode 100644 index df04d454f..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.113.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.114.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.114.woff2 deleted file mode 100644 index 30fa7ecbb..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.114.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.115.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.115.woff2 deleted file mode 100644 index 4f3cc6c60..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.115.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.116.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.116.woff2 deleted file mode 100644 index e5c7c3226..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.116.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.117.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.117.woff2 deleted file mode 100644 index fed3e6fcd..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.117.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.118.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.118.woff2 deleted file mode 100644 index 70910814f..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.118.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.119.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.119.woff2 deleted file mode 100644 index 2e00e2edc..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.119.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.15.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.15.woff2 deleted file mode 100644 index 396d04543..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.15.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.16.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.16.woff2 deleted file mode 100644 index f6e87d22f..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.16.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.17.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.17.woff2 deleted file mode 100644 index 49fc8bf6d..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.17.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.2.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.2.woff2 deleted file mode 100644 index 8431dfaf3..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.2.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.25.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.25.woff2 deleted file mode 100644 index 90eb0d14b..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.25.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.26.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.26.woff2 deleted file mode 100644 index 2f516b040..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.26.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.27.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.27.woff2 deleted file mode 100644 index 95c4586b3..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.27.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.28.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.28.woff2 deleted file mode 100644 index 5a959d520..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.28.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.29.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.29.woff2 deleted file mode 100644 index 4cf56f96e..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.29.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.3.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.3.woff2 deleted file mode 100644 index df780c7bb..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.3.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.30.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.30.woff2 deleted file mode 100644 index c2a2699fa..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.30.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.31.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.31.woff2 deleted file mode 100644 index 932ecc70b..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.31.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.32.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.32.woff2 deleted file mode 100644 index 047befd49..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.32.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.33.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.33.woff2 deleted file mode 100644 index a3781d483..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.33.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.34.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.34.woff2 deleted file mode 100644 index 6dd34cc6c..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.34.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.35.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.35.woff2 deleted file mode 100644 index 6b4c311d9..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.35.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.36.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.36.woff2 deleted file mode 100644 index 3e3cbc2be..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.36.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.37.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.37.woff2 deleted file mode 100644 index 1b9c59d96..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.37.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.38.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.38.woff2 deleted file mode 100644 index 331f88458..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.38.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.39.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.39.woff2 deleted file mode 100644 index 2b401d9a3..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.39.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.4.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.4.woff2 deleted file mode 100644 index 08034ec8f..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.4.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.40.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.40.woff2 deleted file mode 100644 index 109426c32..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.40.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.41.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.41.woff2 deleted file mode 100644 index 57245980f..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.41.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.42.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.42.woff2 deleted file mode 100644 index c615fa97a..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.42.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.43.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.43.woff2 deleted file mode 100644 index eeb8f230b..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.43.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.44.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.44.woff2 deleted file mode 100644 index 7056f6d5e..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.44.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.45.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.45.woff2 deleted file mode 100644 index 256ea2d90..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.45.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.46.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.46.woff2 deleted file mode 100644 index 61cf17a86..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.46.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.47.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.47.woff2 deleted file mode 100644 index cc0563871..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.47.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.48.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.48.woff2 deleted file mode 100644 index d02269e9c..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.48.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.49.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.49.woff2 deleted file mode 100644 index 8388a8368..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.49.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.5.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.5.woff2 deleted file mode 100644 index 7eade50c5..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.5.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.50.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.50.woff2 deleted file mode 100644 index f6b2dd622..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.50.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.51.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.51.woff2 deleted file mode 100644 index 57ad60368..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.51.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.52.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.52.woff2 deleted file mode 100644 index e0e120efc..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.52.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.53.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.53.woff2 deleted file mode 100644 index 6d9640e3e..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.53.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.54.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.54.woff2 deleted file mode 100644 index 5acb6141d..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.54.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.55.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.55.woff2 deleted file mode 100644 index b980f477b..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.55.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.56.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.56.woff2 deleted file mode 100644 index 7af3ea4c6..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.56.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.57.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.57.woff2 deleted file mode 100644 index 24d5a81f5..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.57.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.58.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.58.woff2 deleted file mode 100644 index d7bb584f1..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.58.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.59.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.59.woff2 deleted file mode 100644 index 0438c6d32..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.59.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.6.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.6.woff2 deleted file mode 100644 index 0177c24f1..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.6.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.60.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.60.woff2 deleted file mode 100644 index 05b02b4c7..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.60.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.61.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.61.woff2 deleted file mode 100644 index 443dc8e3e..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.61.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.62.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.62.woff2 deleted file mode 100644 index f5d11c1c0..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.62.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.63.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.63.woff2 deleted file mode 100644 index e5eef5143..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.63.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.64.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.64.woff2 deleted file mode 100644 index 64a0a48ad..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.64.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.65.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.65.woff2 deleted file mode 100644 index 8191417db..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.65.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.66.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.66.woff2 deleted file mode 100644 index 848efa514..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.66.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.67.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.67.woff2 deleted file mode 100644 index 5a4014758..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.67.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.68.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.68.woff2 deleted file mode 100644 index 0e8d440ed..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.68.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.69.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.69.woff2 deleted file mode 100644 index 6a1d85132..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.69.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.7.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.7.woff2 deleted file mode 100644 index 21f3efee5..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.7.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.70.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.70.woff2 deleted file mode 100644 index 20fde22dc..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.70.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.71.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.71.woff2 deleted file mode 100644 index 57b2a2aff..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.71.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.72.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.72.woff2 deleted file mode 100644 index d67610d49..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.72.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.73.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.73.woff2 deleted file mode 100644 index 5de3b3dfc..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.73.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.74.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.74.woff2 deleted file mode 100644 index bf19a3f44..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.74.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.75.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.75.woff2 deleted file mode 100644 index 4895154e4..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.75.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.76.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.76.woff2 deleted file mode 100644 index 7c6367d79..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.76.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.77.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.77.woff2 deleted file mode 100644 index 1093b7e22..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.77.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.78.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.78.woff2 deleted file mode 100644 index b5bd2eecf..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.78.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.79.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.79.woff2 deleted file mode 100644 index fbffe899a..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.79.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.8.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.8.woff2 deleted file mode 100644 index 998612bbd..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.8.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.80.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.80.woff2 deleted file mode 100644 index 93107c89f..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.80.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.81.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.81.woff2 deleted file mode 100644 index 7c9b30d36..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.81.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.82.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.82.woff2 deleted file mode 100644 index fb2f6282b..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.82.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.83.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.83.woff2 deleted file mode 100644 index 35e117203..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.83.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.84.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.84.woff2 deleted file mode 100644 index 811bb52c5..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.84.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.85.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.85.woff2 deleted file mode 100644 index aeecb5dba..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.85.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.86.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.86.woff2 deleted file mode 100644 index f47f29776..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.86.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.87.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.87.woff2 deleted file mode 100644 index e1b766093..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.87.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.88.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.88.woff2 deleted file mode 100644 index 33f93f111..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.88.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.89.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.89.woff2 deleted file mode 100644 index ac11ac9e8..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.89.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.9.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.9.woff2 deleted file mode 100644 index 2fa96da4f..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.9.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.90.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.90.woff2 deleted file mode 100644 index 94ae75e01..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.90.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.91.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.91.woff2 deleted file mode 100644 index 73dede8b1..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.91.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.92.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.92.woff2 deleted file mode 100644 index e5929abd1..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.92.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.93.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.93.woff2 deleted file mode 100644 index 7ed2f336e..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.93.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.98.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.98.woff2 deleted file mode 100644 index c308ee355..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.98.woff2 and /dev/null differ diff --git a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.99.woff2 b/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.99.woff2 deleted file mode 100644 index d5d9d1892..000000000 Binary files a/web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.99.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.0.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.0.woff2 deleted file mode 100644 index e136684e7..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.0.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.1.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.1.woff2 deleted file mode 100644 index 457ab59a2..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.1.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.10.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.10.woff2 deleted file mode 100644 index 06cd32551..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.10.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.100.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.100.woff2 deleted file mode 100644 index 25516147a..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.100.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.101.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.101.woff2 deleted file mode 100644 index 5c9a1f2ec..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.101.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.102.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.102.woff2 deleted file mode 100644 index 6948e153f..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.102.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.103.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.103.woff2 deleted file mode 100644 index 9a75e71fe..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.103.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.104.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.104.woff2 deleted file mode 100644 index cc37b1b51..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.104.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.105.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.105.woff2 deleted file mode 100644 index 10eea7136..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.105.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.106.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.106.woff2 deleted file mode 100644 index 4e9fd0c89..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.106.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.107.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.107.woff2 deleted file mode 100644 index a8b1cd4d9..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.107.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.108.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.108.woff2 deleted file mode 100644 index 3a0f23c48..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.108.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.109.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.109.woff2 deleted file mode 100644 index 10139c3ec..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.109.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.11.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.11.woff2 deleted file mode 100644 index 236d15de7..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.11.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.110.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.110.woff2 deleted file mode 100644 index df4760a55..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.110.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.111.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.111.woff2 deleted file mode 100644 index 3643ddf6f..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.111.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.112.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.112.woff2 deleted file mode 100644 index d38688d7e..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.112.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.113.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.113.woff2 deleted file mode 100644 index 5408c67a3..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.113.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.114.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.114.woff2 deleted file mode 100644 index e645639af..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.114.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.115.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.115.woff2 deleted file mode 100644 index 4bbd0489b..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.115.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.116.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.116.woff2 deleted file mode 100644 index d1beebc01..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.116.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.117.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.117.woff2 deleted file mode 100644 index 1b7b34056..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.117.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.118.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.118.woff2 deleted file mode 100644 index 7f261f856..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.118.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.119.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.119.woff2 deleted file mode 100644 index d742c35ad..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.119.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.12.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.12.woff2 deleted file mode 100644 index 374e7f655..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.12.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.13.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.13.woff2 deleted file mode 100644 index bb252bf25..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.13.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.14.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.14.woff2 deleted file mode 100644 index bacee4095..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.14.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.15.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.15.woff2 deleted file mode 100644 index fe1e1a88d..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.15.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.16.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.16.woff2 deleted file mode 100644 index 4cea69875..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.16.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.17.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.17.woff2 deleted file mode 100644 index 6c11c9e01..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.17.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.18.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.18.woff2 deleted file mode 100644 index 3ac3b449d..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.18.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.19.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.19.woff2 deleted file mode 100644 index e0ddbddf3..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.19.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.2.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.2.woff2 deleted file mode 100644 index 7ccff158f..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.2.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.20.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.20.woff2 deleted file mode 100644 index 5256c3824..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.20.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.21.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.21.woff2 deleted file mode 100644 index fd7c44fe2..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.21.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.22.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.22.woff2 deleted file mode 100644 index ec55782d7..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.22.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.23.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.23.woff2 deleted file mode 100644 index d1ed626b7..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.23.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.24.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.24.woff2 deleted file mode 100644 index 196d1d6cf..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.24.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.25.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.25.woff2 deleted file mode 100644 index 1be3363e5..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.25.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.26.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.26.woff2 deleted file mode 100644 index 23ced43a9..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.26.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.27.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.27.woff2 deleted file mode 100644 index 4a3ac2b6f..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.27.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.28.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.28.woff2 deleted file mode 100644 index 34bd0d574..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.28.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.29.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.29.woff2 deleted file mode 100644 index f3cadaefc..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.29.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.3.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.3.woff2 deleted file mode 100644 index 5492f6d00..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.3.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.30.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.30.woff2 deleted file mode 100644 index 78a452040..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.30.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.31.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.31.woff2 deleted file mode 100644 index 56cf5538a..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.31.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.32.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.32.woff2 deleted file mode 100644 index fe7bd52a7..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.32.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.33.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.33.woff2 deleted file mode 100644 index fb45d86f1..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.33.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.34.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.34.woff2 deleted file mode 100644 index bed7d1d3b..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.34.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.35.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.35.woff2 deleted file mode 100644 index d828f5cbe..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.35.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.36.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.36.woff2 deleted file mode 100644 index 3e6191a62..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.36.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.37.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.37.woff2 deleted file mode 100644 index 78170961c..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.37.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.38.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.38.woff2 deleted file mode 100644 index 2e453816d..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.38.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.39.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.39.woff2 deleted file mode 100644 index cf8630c93..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.39.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.4.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.4.woff2 deleted file mode 100644 index 61a132ee2..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.4.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.40.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.40.woff2 deleted file mode 100644 index 77c07411b..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.40.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.41.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.41.woff2 deleted file mode 100644 index 5e7f9ad8d..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.41.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.42.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.42.woff2 deleted file mode 100644 index c8334bcdb..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.42.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.43.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.43.woff2 deleted file mode 100644 index 2e48673d4..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.43.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.44.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.44.woff2 deleted file mode 100644 index 20a7a1ad9..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.44.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.45.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.45.woff2 deleted file mode 100644 index a31aaf4e4..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.45.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.46.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.46.woff2 deleted file mode 100644 index 2f0dc50fc..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.46.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.47.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.47.woff2 deleted file mode 100644 index 5ed9c5b82..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.47.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.48.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.48.woff2 deleted file mode 100644 index 00295393c..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.48.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.49.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.49.woff2 deleted file mode 100644 index ee0e3e046..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.49.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.5.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.5.woff2 deleted file mode 100644 index 587ccdb2a..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.5.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.50.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.50.woff2 deleted file mode 100644 index ab386feca..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.50.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.51.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.51.woff2 deleted file mode 100644 index 17ffb2d0f..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.51.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.52.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.52.woff2 deleted file mode 100644 index d83c7ddbd..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.52.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.53.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.53.woff2 deleted file mode 100644 index a6d288fdf..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.53.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.54.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.54.woff2 deleted file mode 100644 index 05ccb7d25..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.54.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.55.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.55.woff2 deleted file mode 100644 index 3c29c5878..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.55.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.56.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.56.woff2 deleted file mode 100644 index bcb77e447..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.56.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.57.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.57.woff2 deleted file mode 100644 index c76565ef9..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.57.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.58.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.58.woff2 deleted file mode 100644 index 209f8faef..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.58.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.59.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.59.woff2 deleted file mode 100644 index 01e387169..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.59.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.6.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.6.woff2 deleted file mode 100644 index 0b89b5ba3..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.6.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.60.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.60.woff2 deleted file mode 100644 index 4ac30e0ba..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.60.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.61.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.61.woff2 deleted file mode 100644 index ace52c716..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.61.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.62.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.62.woff2 deleted file mode 100644 index f30b9a706..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.62.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.63.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.63.woff2 deleted file mode 100644 index af5fba664..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.63.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.64.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.64.woff2 deleted file mode 100644 index 75ee99d4b..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.64.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.65.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.65.woff2 deleted file mode 100644 index 807f5911e..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.65.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.66.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.66.woff2 deleted file mode 100644 index dcced61b1..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.66.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.67.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.67.woff2 deleted file mode 100644 index 649d7a39f..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.67.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.68.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.68.woff2 deleted file mode 100644 index cf43763b5..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.68.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.69.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.69.woff2 deleted file mode 100644 index c1ea7f7aa..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.69.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.7.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.7.woff2 deleted file mode 100644 index cf7dde32d..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.7.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.70.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.70.woff2 deleted file mode 100644 index da6139b2a..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.70.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.71.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.71.woff2 deleted file mode 100644 index e202d64f1..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.71.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.72.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.72.woff2 deleted file mode 100644 index a01349891..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.72.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.73.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.73.woff2 deleted file mode 100644 index 3fb753ca8..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.73.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.74.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.74.woff2 deleted file mode 100644 index 1b304d8f7..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.74.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.75.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.75.woff2 deleted file mode 100644 index 108d8aad3..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.75.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.76.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.76.woff2 deleted file mode 100644 index 9b79a9bd1..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.76.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.77.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.77.woff2 deleted file mode 100644 index 4d69645ff..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.77.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.78.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.78.woff2 deleted file mode 100644 index f29d0fe17..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.78.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.79.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.79.woff2 deleted file mode 100644 index 5700d7f66..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.79.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.8.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.8.woff2 deleted file mode 100644 index 3b758ed6c..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.8.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.80.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.80.woff2 deleted file mode 100644 index ffc3d7b09..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.80.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.81.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.81.woff2 deleted file mode 100644 index 75cc447d9..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.81.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.82.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.82.woff2 deleted file mode 100644 index 2d5abf38c..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.82.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.83.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.83.woff2 deleted file mode 100644 index fbf4e9224..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.83.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.84.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.84.woff2 deleted file mode 100644 index f6f632ef0..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.84.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.85.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.85.woff2 deleted file mode 100644 index 4f3032dcb..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.85.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.86.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.86.woff2 deleted file mode 100644 index c2f89005b..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.86.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.87.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.87.woff2 deleted file mode 100644 index 7a79e1fbd..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.87.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.88.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.88.woff2 deleted file mode 100644 index 7219ed8ec..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.88.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.89.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.89.woff2 deleted file mode 100644 index 009e56534..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.89.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.9.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.9.woff2 deleted file mode 100644 index d19d7d2db..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.9.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.90.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.90.woff2 deleted file mode 100644 index e1a016cf0..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.90.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.91.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.91.woff2 deleted file mode 100644 index a39ae2bf6..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.91.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.92.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.92.woff2 deleted file mode 100644 index 5319ff843..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.92.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.93.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.93.woff2 deleted file mode 100644 index ae6e976e7..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.93.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.94.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.94.woff2 deleted file mode 100644 index 9f85104f2..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.94.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.95.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.95.woff2 deleted file mode 100644 index c12127e90..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.95.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.96.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.96.woff2 deleted file mode 100644 index 219f26f56..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.96.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.97.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.97.woff2 deleted file mode 100644 index 5beb54c4e..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.97.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.98.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.98.woff2 deleted file mode 100644 index e4991bbcb..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.98.woff2 and /dev/null differ diff --git a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.99.woff2 b/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.99.woff2 deleted file mode 100644 index a1a5193a8..000000000 Binary files a/web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.99.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.0.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.0.woff2 deleted file mode 100644 index 0b52e3386..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.0.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.1.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.1.woff2 deleted file mode 100644 index 316aeaaa3..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.1.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.10.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.10.woff2 deleted file mode 100644 index 8e122092a..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.10.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.100.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.100.woff2 deleted file mode 100644 index 26a35ec63..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.100.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.101.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.101.woff2 deleted file mode 100644 index 11eb578a8..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.101.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.102.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.102.woff2 deleted file mode 100644 index e7b2ea46c..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.102.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.103.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.103.woff2 deleted file mode 100644 index 0ea303ef8..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.103.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.104.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.104.woff2 deleted file mode 100644 index e6123454a..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.104.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.105.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.105.woff2 deleted file mode 100644 index 9c9828028..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.105.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.106.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.106.woff2 deleted file mode 100644 index dcd96d965..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.106.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.107.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.107.woff2 deleted file mode 100644 index 53c91e15f..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.107.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.108.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.108.woff2 deleted file mode 100644 index 2277bf0f1..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.108.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.109.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.109.woff2 deleted file mode 100644 index 64e21b090..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.109.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.11.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.11.woff2 deleted file mode 100644 index c3053320f..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.11.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.110.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.110.woff2 deleted file mode 100644 index b7297a93e..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.110.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.111.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.111.woff2 deleted file mode 100644 index 8a70cfcdc..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.111.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.112.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.112.woff2 deleted file mode 100644 index e16f8be5f..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.112.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.113.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.113.woff2 deleted file mode 100644 index a4bfe1008..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.113.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.114.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.114.woff2 deleted file mode 100644 index 16cbb955d..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.114.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.115.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.115.woff2 deleted file mode 100644 index 569c3c0b9..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.115.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.116.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.116.woff2 deleted file mode 100644 index 5037ff7a7..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.116.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.117.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.117.woff2 deleted file mode 100644 index c0e667faf..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.117.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.118.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.118.woff2 deleted file mode 100644 index ca5075ed8..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.118.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.119.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.119.woff2 deleted file mode 100644 index 53dc8bb83..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.119.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.12.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.12.woff2 deleted file mode 100644 index 8cedf37a5..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.12.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.13.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.13.woff2 deleted file mode 100644 index c3c82a446..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.13.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.14.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.14.woff2 deleted file mode 100644 index 0a7df50f1..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.14.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.15.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.15.woff2 deleted file mode 100644 index e7e75dab4..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.15.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.16.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.16.woff2 deleted file mode 100644 index 62d824afd..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.16.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.17.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.17.woff2 deleted file mode 100644 index c2fb647dc..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.17.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.18.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.18.woff2 deleted file mode 100644 index d2588c6bf..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.18.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.19.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.19.woff2 deleted file mode 100644 index 7fb0314a9..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.19.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.2.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.2.woff2 deleted file mode 100644 index 6622253c2..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.2.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.20.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.20.woff2 deleted file mode 100644 index 2256f5a11..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.20.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.21.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.21.woff2 deleted file mode 100644 index 0446c8c5e..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.21.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.22.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.22.woff2 deleted file mode 100644 index c5f0a6c7a..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.22.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.23.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.23.woff2 deleted file mode 100644 index 2edc8a0a0..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.23.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.24.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.24.woff2 deleted file mode 100644 index bda3d0344..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.24.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.25.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.25.woff2 deleted file mode 100644 index 5fbf8676b..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.25.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.26.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.26.woff2 deleted file mode 100644 index 955102451..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.26.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.27.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.27.woff2 deleted file mode 100644 index 53d9233b0..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.27.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.28.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.28.woff2 deleted file mode 100644 index 907e020c7..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.28.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.29.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.29.woff2 deleted file mode 100644 index 6aca9206a..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.29.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.3.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.3.woff2 deleted file mode 100644 index 76b75f7f1..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.3.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.30.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.30.woff2 deleted file mode 100644 index d6035677d..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.30.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.31.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.31.woff2 deleted file mode 100644 index fdf5e0a8a..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.31.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.32.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.32.woff2 deleted file mode 100644 index 25b19c439..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.32.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.33.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.33.woff2 deleted file mode 100644 index 523283eda..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.33.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.34.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.34.woff2 deleted file mode 100644 index 2997daf13..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.34.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.35.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.35.woff2 deleted file mode 100644 index 28d496ee8..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.35.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.36.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.36.woff2 deleted file mode 100644 index 6919af924..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.36.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.37.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.37.woff2 deleted file mode 100644 index 143ad4dcb..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.37.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.38.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.38.woff2 deleted file mode 100644 index fa8bb215f..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.38.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.39.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.39.woff2 deleted file mode 100644 index 34725edb9..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.39.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.4.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.4.woff2 deleted file mode 100644 index fd5e7f8c7..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.4.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.40.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.40.woff2 deleted file mode 100644 index aeaad3f4c..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.40.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.41.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.41.woff2 deleted file mode 100644 index ca254656f..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.41.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.42.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.42.woff2 deleted file mode 100644 index 4f0582155..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.42.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.43.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.43.woff2 deleted file mode 100644 index 370a84fad..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.43.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.44.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.44.woff2 deleted file mode 100644 index 64193b0f1..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.44.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.45.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.45.woff2 deleted file mode 100644 index 397c5bcf7..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.45.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.46.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.46.woff2 deleted file mode 100644 index 00ebfb8ba..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.46.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.47.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.47.woff2 deleted file mode 100644 index a27f3c1ba..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.47.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.48.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.48.woff2 deleted file mode 100644 index 222162e1a..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.48.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.49.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.49.woff2 deleted file mode 100644 index de901ed1c..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.49.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.5.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.5.woff2 deleted file mode 100644 index 7e1344c24..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.5.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.50.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.50.woff2 deleted file mode 100644 index 598c71940..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.50.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.51.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.51.woff2 deleted file mode 100644 index 44c35463b..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.51.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.52.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.52.woff2 deleted file mode 100644 index ea984bfac..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.52.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.53.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.53.woff2 deleted file mode 100644 index 505f809e1..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.53.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.54.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.54.woff2 deleted file mode 100644 index 8343d8eab..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.54.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.55.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.55.woff2 deleted file mode 100644 index 2dce008ea..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.55.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.56.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.56.woff2 deleted file mode 100644 index c0904609e..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.56.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.57.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.57.woff2 deleted file mode 100644 index e705d848e..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.57.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.58.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.58.woff2 deleted file mode 100644 index 6045ba31f..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.58.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.59.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.59.woff2 deleted file mode 100644 index f2350380f..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.59.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.6.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.6.woff2 deleted file mode 100644 index 4586ac1ab..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.6.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.60.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.60.woff2 deleted file mode 100644 index e27d42449..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.60.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.61.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.61.woff2 deleted file mode 100644 index 086af5fe6..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.61.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.62.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.62.woff2 deleted file mode 100644 index 4ca1aa9e7..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.62.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.63.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.63.woff2 deleted file mode 100644 index f9ea7bce0..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.63.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.64.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.64.woff2 deleted file mode 100644 index a25645bc1..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.64.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.65.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.65.woff2 deleted file mode 100644 index 05bd7585a..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.65.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.66.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.66.woff2 deleted file mode 100644 index 7101b02ee..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.66.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.67.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.67.woff2 deleted file mode 100644 index 8bfcf4e6c..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.67.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.68.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.68.woff2 deleted file mode 100644 index c3e66dfd8..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.68.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.69.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.69.woff2 deleted file mode 100644 index 6871606a8..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.69.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.7.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.7.woff2 deleted file mode 100644 index 78b9c3645..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.7.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.70.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.70.woff2 deleted file mode 100644 index 625608a0a..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.70.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.71.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.71.woff2 deleted file mode 100644 index 27c338ffb..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.71.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.72.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.72.woff2 deleted file mode 100644 index eca879beb..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.72.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.73.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.73.woff2 deleted file mode 100644 index 1461a5c2e..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.73.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.74.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.74.woff2 deleted file mode 100644 index 55292e1ec..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.74.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.75.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.75.woff2 deleted file mode 100644 index e85b5870f..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.75.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.76.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.76.woff2 deleted file mode 100644 index 0d94fa8b1..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.76.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.77.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.77.woff2 deleted file mode 100644 index cb868e671..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.77.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.78.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.78.woff2 deleted file mode 100644 index 35435dc63..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.78.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.79.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.79.woff2 deleted file mode 100644 index 2a706cf50..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.79.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.8.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.8.woff2 deleted file mode 100644 index 1ac76d21b..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.8.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.80.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.80.woff2 deleted file mode 100644 index 3285c792d..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.80.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.81.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.81.woff2 deleted file mode 100644 index fb4505e21..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.81.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.82.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.82.woff2 deleted file mode 100644 index 64905dbd1..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.82.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.83.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.83.woff2 deleted file mode 100644 index b9de445fe..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.83.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.84.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.84.woff2 deleted file mode 100644 index 34e913d20..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.84.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.85.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.85.woff2 deleted file mode 100644 index 276de9793..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.85.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.86.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.86.woff2 deleted file mode 100644 index 422228f5c..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.86.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.87.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.87.woff2 deleted file mode 100644 index d445542a1..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.87.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.88.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.88.woff2 deleted file mode 100644 index c019d1b33..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.88.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.89.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.89.woff2 deleted file mode 100644 index d23356c1f..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.89.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.9.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.9.woff2 deleted file mode 100644 index c89c6b8ab..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.9.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.90.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.90.woff2 deleted file mode 100644 index 9783418e0..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.90.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.91.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.91.woff2 deleted file mode 100644 index 8b5702460..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.91.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.92.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.92.woff2 deleted file mode 100644 index a9afacc47..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.92.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.93.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.93.woff2 deleted file mode 100644 index d8411c41f..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.93.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.94.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.94.woff2 deleted file mode 100644 index ef6faf3e8..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.94.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.95.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.95.woff2 deleted file mode 100644 index f321ae753..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.95.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.96.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.96.woff2 deleted file mode 100644 index 2a00f020a..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.96.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.97.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.97.woff2 deleted file mode 100644 index b83fc201f..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.97.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.98.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.98.woff2 deleted file mode 100644 index 9e8394dd7..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.98.woff2 and /dev/null differ diff --git a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.99.woff2 b/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.99.woff2 deleted file mode 100644 index c69e70af5..000000000 Binary files a/web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.99.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.100.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.100.woff2 deleted file mode 100644 index 70a309feb..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.100.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.101.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.101.woff2 deleted file mode 100644 index 4595dce12..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.101.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.102.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.102.woff2 deleted file mode 100644 index 9d87f4e6e..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.102.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.103.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.103.woff2 deleted file mode 100644 index d919a71c3..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.103.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.104.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.104.woff2 deleted file mode 100644 index aac555e10..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.104.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.105.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.105.woff2 deleted file mode 100644 index 60722c837..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.105.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.106.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.106.woff2 deleted file mode 100644 index 19bc0cfbc..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.106.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.107.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.107.woff2 deleted file mode 100644 index 0a9df76cf..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.107.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.108.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.108.woff2 deleted file mode 100644 index 8bca4267b..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.108.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.109.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.109.woff2 deleted file mode 100644 index 0ee33dd29..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.109.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.110.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.110.woff2 deleted file mode 100644 index 04e06df6a..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.110.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.111.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.111.woff2 deleted file mode 100644 index a1f2d7a00..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.111.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.112.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.112.woff2 deleted file mode 100644 index f8284f378..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.112.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.113.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.113.woff2 deleted file mode 100644 index 8b686f6cf..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.113.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.114.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.114.woff2 deleted file mode 100644 index 174601caa..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.114.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.115.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.115.woff2 deleted file mode 100644 index bfc872980..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.115.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.116.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.116.woff2 deleted file mode 100644 index a569ab9c2..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.116.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.117.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.117.woff2 deleted file mode 100644 index 460f67ed0..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.117.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.118.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.118.woff2 deleted file mode 100644 index 3418a4a2f..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.118.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.119.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.119.woff2 deleted file mode 100644 index aa9d5ea47..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.119.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.21.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.21.woff2 deleted file mode 100644 index 2d2347b69..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.21.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.22.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.22.woff2 deleted file mode 100644 index f0d0487fe..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.22.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.23.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.23.woff2 deleted file mode 100644 index 9167ea8a7..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.23.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.24.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.24.woff2 deleted file mode 100644 index 62e3a4b11..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.24.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.25.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.25.woff2 deleted file mode 100644 index e07cbc6a5..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.25.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.26.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.26.woff2 deleted file mode 100644 index 2902291bc..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.26.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.27.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.27.woff2 deleted file mode 100644 index e6bb9410e..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.27.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.28.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.28.woff2 deleted file mode 100644 index 72403ce23..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.28.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.29.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.29.woff2 deleted file mode 100644 index 93844b826..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.29.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.30.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.30.woff2 deleted file mode 100644 index 54745691f..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.30.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.31.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.31.woff2 deleted file mode 100644 index 4d306b329..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.31.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.32.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.32.woff2 deleted file mode 100644 index f7acc8913..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.32.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.33.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.33.woff2 deleted file mode 100644 index 6fd8bae8c..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.33.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.34.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.34.woff2 deleted file mode 100644 index 264c2224b..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.34.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.35.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.35.woff2 deleted file mode 100644 index e0a201122..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.35.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.36.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.36.woff2 deleted file mode 100644 index 109b34861..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.36.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.37.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.37.woff2 deleted file mode 100644 index a87b2a9f8..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.37.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.38.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.38.woff2 deleted file mode 100644 index 9208b4a24..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.38.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.39.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.39.woff2 deleted file mode 100644 index 015a0e90d..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.39.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.4.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.4.woff2 deleted file mode 100644 index 05702b59a..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.4.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.40.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.40.woff2 deleted file mode 100644 index 2b5a7540d..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.40.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.41.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.41.woff2 deleted file mode 100644 index 07607082b..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.41.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.42.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.42.woff2 deleted file mode 100644 index 085b4205c..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.42.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.43.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.43.woff2 deleted file mode 100644 index c7d8723dd..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.43.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.44.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.44.woff2 deleted file mode 100644 index 745796790..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.44.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.45.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.45.woff2 deleted file mode 100644 index b8306825d..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.45.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.46.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.46.woff2 deleted file mode 100644 index d058ea7f3..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.46.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.47.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.47.woff2 deleted file mode 100644 index 55b9345ec..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.47.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.48.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.48.woff2 deleted file mode 100644 index f62fd1c22..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.48.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.49.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.49.woff2 deleted file mode 100644 index d58ddb114..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.49.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.5.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.5.woff2 deleted file mode 100644 index 5958c6f77..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.5.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.50.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.50.woff2 deleted file mode 100644 index 8aa577e10..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.50.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.51.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.51.woff2 deleted file mode 100644 index 8d9e9b053..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.51.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.52.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.52.woff2 deleted file mode 100644 index e96a6b582..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.52.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.53.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.53.woff2 deleted file mode 100644 index fedb204ae..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.53.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.54.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.54.woff2 deleted file mode 100644 index a879a12de..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.54.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.55.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.55.woff2 deleted file mode 100644 index 9f0b2eed3..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.55.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.56.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.56.woff2 deleted file mode 100644 index ffb985341..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.56.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.57.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.57.woff2 deleted file mode 100644 index 0fa84978a..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.57.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.58.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.58.woff2 deleted file mode 100644 index 0e8d64da7..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.58.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.59.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.59.woff2 deleted file mode 100644 index 223c7247d..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.59.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.6.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.6.woff2 deleted file mode 100644 index 5621bbcb5..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.6.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.60.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.60.woff2 deleted file mode 100644 index 739985de2..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.60.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.61.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.61.woff2 deleted file mode 100644 index 28f76a195..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.61.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.62.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.62.woff2 deleted file mode 100644 index 314501195..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.62.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.63.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.63.woff2 deleted file mode 100644 index a487dbffd..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.63.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.64.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.64.woff2 deleted file mode 100644 index d0c7818a7..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.64.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.65.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.65.woff2 deleted file mode 100644 index f66f3fbb6..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.65.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.66.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.66.woff2 deleted file mode 100644 index 6b23587e4..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.66.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.67.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.67.woff2 deleted file mode 100644 index 63198895d..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.67.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.68.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.68.woff2 deleted file mode 100644 index 23d8cc600..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.68.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.69.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.69.woff2 deleted file mode 100644 index 9d6c5d632..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.69.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.70.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.70.woff2 deleted file mode 100644 index 6747e9120..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.70.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.71.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.71.woff2 deleted file mode 100644 index 57d930190..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.71.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.72.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.72.woff2 deleted file mode 100644 index 61c4306ba..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.72.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.73.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.73.woff2 deleted file mode 100644 index 1c87fac34..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.73.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.74.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.74.woff2 deleted file mode 100644 index abaf7260f..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.74.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.75.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.75.woff2 deleted file mode 100644 index 306a71aaa..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.75.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.76.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.76.woff2 deleted file mode 100644 index e85164827..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.76.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.77.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.77.woff2 deleted file mode 100644 index 9a1a67238..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.77.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.78.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.78.woff2 deleted file mode 100644 index d5896178c..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.78.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.79.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.79.woff2 deleted file mode 100644 index 80936f1eb..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.79.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.80.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.80.woff2 deleted file mode 100644 index 5b6e1ff44..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.80.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.81.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.81.woff2 deleted file mode 100644 index 7a5bcf53e..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.81.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.82.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.82.woff2 deleted file mode 100644 index 8da736e45..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.82.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.83.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.83.woff2 deleted file mode 100644 index 82241ad84..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.83.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.84.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.84.woff2 deleted file mode 100644 index c8b63ca4c..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.84.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.85.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.85.woff2 deleted file mode 100644 index afe4432d4..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.85.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.86.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.86.woff2 deleted file mode 100644 index 6568dae4e..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.86.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.87.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.87.woff2 deleted file mode 100644 index e750bd43b..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.87.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.88.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.88.woff2 deleted file mode 100644 index 7a3a99613..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.88.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.89.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.89.woff2 deleted file mode 100644 index 5773aacbe..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.89.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.90.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.90.woff2 deleted file mode 100644 index e7a52825a..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.90.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.91.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.91.woff2 deleted file mode 100644 index 9a23dc918..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.91.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.97.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.97.woff2 deleted file mode 100644 index 45cb44986..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.97.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.98.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.98.woff2 deleted file mode 100644 index ad128278c..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.98.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.99.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.99.woff2 deleted file mode 100644 index 3b3658847..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.99.woff2 and /dev/null differ diff --git a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FrY9HbczS.woff2 b/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FrY9HbczS.woff2 deleted file mode 100644 index a43a983ce..000000000 Binary files a/web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FrY9HbczS.woff2 and /dev/null differ diff --git a/web/assets/notosanssymbols/v43/rP2up3q65FkAtHfwd-eIS2brbDN6gxP34F9jRRCe4W3gfQ8gb_VFRkzrbQ.woff2 b/web/assets/notosanssymbols/v43/rP2up3q65FkAtHfwd-eIS2brbDN6gxP34F9jRRCe4W3gfQ8gb_VFRkzrbQ.woff2 deleted file mode 100644 index 12f2f80a3..000000000 Binary files a/web/assets/notosanssymbols/v43/rP2up3q65FkAtHfwd-eIS2brbDN6gxP34F9jRRCe4W3gfQ8gb_VFRkzrbQ.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.0.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.0.woff2 deleted file mode 100644 index 61efdbd61..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.0.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.100.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.100.woff2 deleted file mode 100644 index 3f2d88d39..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.100.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.101.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.101.woff2 deleted file mode 100644 index 43b507eb1..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.101.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.102.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.102.woff2 deleted file mode 100644 index 5fc8ecd2c..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.102.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.103.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.103.woff2 deleted file mode 100644 index e870b8cbd..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.103.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.104.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.104.woff2 deleted file mode 100644 index eaf9ebffb..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.104.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.105.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.105.woff2 deleted file mode 100644 index 1c7df0704..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.105.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.106.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.106.woff2 deleted file mode 100644 index b0062f904..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.106.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.107.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.107.woff2 deleted file mode 100644 index 0daee5ee1..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.107.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.108.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.108.woff2 deleted file mode 100644 index dc39ae3d8..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.108.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.109.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.109.woff2 deleted file mode 100644 index ac69aa505..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.109.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.110.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.110.woff2 deleted file mode 100644 index 98d3bd678..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.110.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.111.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.111.woff2 deleted file mode 100644 index 1da87e095..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.111.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.112.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.112.woff2 deleted file mode 100644 index 54fc3a22d..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.112.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.113.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.113.woff2 deleted file mode 100644 index e14ae0d58..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.113.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.114.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.114.woff2 deleted file mode 100644 index 256456107..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.114.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.115.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.115.woff2 deleted file mode 100644 index 023192c29..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.115.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.116.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.116.woff2 deleted file mode 100644 index 3ed63b4c4..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.116.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.117.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.117.woff2 deleted file mode 100644 index 6b48fa81f..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.117.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.118.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.118.woff2 deleted file mode 100644 index cffce8e46..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.118.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.119.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.119.woff2 deleted file mode 100644 index 53512c7fe..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.119.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.19.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.19.woff2 deleted file mode 100644 index f548b8269..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.19.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.20.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.20.woff2 deleted file mode 100644 index 97d1b4e75..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.20.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.21.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.21.woff2 deleted file mode 100644 index d7f8d32c7..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.21.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.22.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.22.woff2 deleted file mode 100644 index 24ea35615..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.22.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.23.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.23.woff2 deleted file mode 100644 index 1eb29327f..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.23.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.24.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.24.woff2 deleted file mode 100644 index 7ac68e432..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.24.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.25.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.25.woff2 deleted file mode 100644 index a8ed5302d..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.25.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.26.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.26.woff2 deleted file mode 100644 index f42393573..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.26.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.27.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.27.woff2 deleted file mode 100644 index e54be53c1..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.27.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.28.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.28.woff2 deleted file mode 100644 index bbd44921d..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.28.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.29.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.29.woff2 deleted file mode 100644 index d46b9e92d..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.29.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.30.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.30.woff2 deleted file mode 100644 index 6291f9c2e..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.30.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.31.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.31.woff2 deleted file mode 100644 index 216c2945d..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.31.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.32.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.32.woff2 deleted file mode 100644 index d8f7febbc..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.32.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.33.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.33.woff2 deleted file mode 100644 index b7dcfa02a..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.33.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.34.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.34.woff2 deleted file mode 100644 index 0d02d7d0a..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.34.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.35.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.35.woff2 deleted file mode 100644 index 05baef3e4..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.35.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.36.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.36.woff2 deleted file mode 100644 index ffdf2e5d0..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.36.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.37.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.37.woff2 deleted file mode 100644 index 20fdeb8b9..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.37.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.38.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.38.woff2 deleted file mode 100644 index 84ff75e62..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.38.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.39.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.39.woff2 deleted file mode 100644 index 04b366189..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.39.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.40.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.40.woff2 deleted file mode 100644 index e2994f142..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.40.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.41.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.41.woff2 deleted file mode 100644 index 0a296fcf9..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.41.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.42.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.42.woff2 deleted file mode 100644 index 32ca97274..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.42.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.43.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.43.woff2 deleted file mode 100644 index d84b989b7..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.43.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.44.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.44.woff2 deleted file mode 100644 index 9a9192d0e..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.44.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.45.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.45.woff2 deleted file mode 100644 index 508b4185e..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.45.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.46.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.46.woff2 deleted file mode 100644 index 70164ecf9..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.46.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.47.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.47.woff2 deleted file mode 100644 index b79cdb344..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.47.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.48.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.48.woff2 deleted file mode 100644 index ac35fa9b4..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.48.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.49.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.49.woff2 deleted file mode 100644 index bbb9872a9..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.49.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.50.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.50.woff2 deleted file mode 100644 index 9925f68fb..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.50.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.51.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.51.woff2 deleted file mode 100644 index 19a3c27e7..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.51.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.52.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.52.woff2 deleted file mode 100644 index 6138d02b7..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.52.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.53.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.53.woff2 deleted file mode 100644 index 24e959a33..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.53.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.54.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.54.woff2 deleted file mode 100644 index 5b2642fad..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.54.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.55.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.55.woff2 deleted file mode 100644 index 18c6d3977..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.55.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.56.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.56.woff2 deleted file mode 100644 index f7b0a9b33..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.56.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.57.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.57.woff2 deleted file mode 100644 index 8fa7fb5b9..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.57.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.58.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.58.woff2 deleted file mode 100644 index 13c3f85b0..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.58.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.59.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.59.woff2 deleted file mode 100644 index 460a05964..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.59.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.6.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.6.woff2 deleted file mode 100644 index 355218807..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.6.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.60.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.60.woff2 deleted file mode 100644 index f9c1e4b57..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.60.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.61.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.61.woff2 deleted file mode 100644 index 204760bee..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.61.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.62.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.62.woff2 deleted file mode 100644 index 81517ab12..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.62.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.63.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.63.woff2 deleted file mode 100644 index f12bb2e45..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.63.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.64.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.64.woff2 deleted file mode 100644 index 172c210ab..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.64.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.65.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.65.woff2 deleted file mode 100644 index a9c90a888..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.65.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.66.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.66.woff2 deleted file mode 100644 index 96a4992c2..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.66.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.67.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.67.woff2 deleted file mode 100644 index 817655e2e..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.67.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.68.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.68.woff2 deleted file mode 100644 index 981e95e16..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.68.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.69.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.69.woff2 deleted file mode 100644 index 2c06a668d..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.69.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.7.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.7.woff2 deleted file mode 100644 index 602114e67..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.7.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.70.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.70.woff2 deleted file mode 100644 index dabaada7c..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.70.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.71.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.71.woff2 deleted file mode 100644 index 9af3b377e..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.71.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.72.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.72.woff2 deleted file mode 100644 index fc25b83c4..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.72.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.73.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.73.woff2 deleted file mode 100644 index 7703953ec..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.73.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.74.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.74.woff2 deleted file mode 100644 index 7b442d141..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.74.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.75.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.75.woff2 deleted file mode 100644 index ce638d128..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.75.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.76.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.76.woff2 deleted file mode 100644 index 18a799c43..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.76.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.77.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.77.woff2 deleted file mode 100644 index 36dfb8cb6..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.77.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.78.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.78.woff2 deleted file mode 100644 index 0c605059d..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.78.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.79.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.79.woff2 deleted file mode 100644 index 791f34d62..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.79.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.8.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.8.woff2 deleted file mode 100644 index 38ec3f510..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.8.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.80.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.80.woff2 deleted file mode 100644 index 683c27a4e..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.80.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.81.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.81.woff2 deleted file mode 100644 index e1af19ba8..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.81.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.82.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.82.woff2 deleted file mode 100644 index b68013605..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.82.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.83.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.83.woff2 deleted file mode 100644 index 61d16ee03..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.83.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.84.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.84.woff2 deleted file mode 100644 index a056f4283..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.84.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.85.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.85.woff2 deleted file mode 100644 index fb9399a27..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.85.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.86.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.86.woff2 deleted file mode 100644 index 45a33760d..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.86.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.87.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.87.woff2 deleted file mode 100644 index 1f67193ad..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.87.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.88.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.88.woff2 deleted file mode 100644 index 32e7adc0d..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.88.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.89.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.89.woff2 deleted file mode 100644 index feaebaf01..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.89.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.90.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.90.woff2 deleted file mode 100644 index 60d8d5048..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.90.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.91.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.91.woff2 deleted file mode 100644 index 086877da4..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.91.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.92.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.92.woff2 deleted file mode 100644 index 9981fc543..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.92.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.97.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.97.woff2 deleted file mode 100644 index 60cfa17c0..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.97.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.98.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.98.woff2 deleted file mode 100644 index 9aeb8845e..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.98.woff2 and /dev/null differ diff --git a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.99.woff2 b/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.99.woff2 deleted file mode 100644 index 324664f1e..000000000 Binary files a/web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.99.woff2 and /dev/null differ diff --git a/web/early-bootstrap.js b/web/early-bootstrap.js new file mode 100644 index 000000000..a46bc13c1 --- /dev/null +++ b/web/early-bootstrap.js @@ -0,0 +1,46 @@ +// early-bootstrap.js +// Extracted inline scripts for CSP compliance (removes need for 'unsafe-inline' in script-src) +// These scripts must run synchronously before body renders. + +(function () { + 'use strict'; + + // 1. PWA install prompt early capture + window.deferredBeforeInstallPromptEvent = null; + window.addEventListener('beforeinstallprompt', function (e) { + e.preventDefault(); + window.deferredBeforeInstallPromptEvent = e; + console.log('PWA: Early capture of beforeinstallprompt'); + }); + + // 2. Theme detection and application + var appSettingsString = window.localStorage.getItem('flutter.AppSettings'); + + if (appSettingsString) { + try { + var settings = JSON.parse(JSON.parse(appSettingsString)); + var themeMode = settings.themeMode; + if (themeMode === 'system') { + var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + document.documentElement.setAttribute('data-theme', prefersDark ? 'dark' : 'light'); + } else { + document.documentElement.setAttribute('data-theme', themeMode); + } + } catch (error) { + console.error('app settings parse error: ', error); + } + } else { + console.log('app settings not found, by system'); + var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + document.documentElement.setAttribute('data-theme', prefersDark ? 'dark' : 'light'); + } + + // 3. Splash screen removal utility + window.removeSplashFromWeb = function () { + var splash = document.getElementById('splash'); + var branding = document.getElementById('splash-branding'); + if (splash) splash.remove(); + if (branding) branding.remove(); + document.body.style.background = 'transparent'; + }; +})(); diff --git a/web/flutter_bootstrap.js b/web/flutter_bootstrap.js index 48cdc77eb..73b7ed0a6 100644 --- a/web/flutter_bootstrap.js +++ b/web/flutter_bootstrap.js @@ -11,7 +11,13 @@ _flutter.buildConfig = {"engineRevision":"cf56914b326edb0ccb123ffdc60f00060bd513 _flutter.loader.load({ config: { - fontFallbackBaseUrl: "./assets/", + // Offline-first fonts are eager-loaded via pubspec fonts: (CJK/non-Latin + // subsets + Roboto), so everything the UI needs renders without network. + // fontFallbackBaseUrl stays on the CDN so that when online, code points + // outside the bundled subsets (e.g. rare user-typed CJK) can still be + // fetched on demand (A+). Offline, these simply don't load — the bundled + // fonts already cover all interface text. + fontFallbackBaseUrl: "https://fonts.gstatic.com/s/", canvasKitBaseUrl: "./assets/" }, serviceWorkerSettings: { diff --git a/web/index.html b/web/index.html index 4ea837b0a..d238ea42e 100644 --- a/web/index.html +++ b/web/index.html @@ -23,15 +23,7 @@ - + @@ -105,38 +97,6 @@ right: 0; } - - diff --git a/web/usp_client.js b/web/usp_client.js index 7a3edf626..cdcca2928 100644 --- a/web/usp_client.js +++ b/web/usp_client.js @@ -372,14 +372,46 @@ export class UspClient { return takeObject(ret); } /** - * Refreshes the authentication token before expiration + * Refreshes the authentication token, optionally restoring from an external token. + * + * This method can be used to: + * 1. Refresh the current session token (when `token` is `undefined`) + * 2. Restore and validate a token from external storage (when `token` is provided) + * + * When restoring from external storage (e.g., localStorage), the provided token + * is sent to the server to request a fresh token. This validates that the token + * is still valid and refreshable. + * + * # Arguments + * * `token` - Optional token to restore. If `undefined`, uses the current session token. * * # Returns - * * Promise that resolves on success, rejects on error + * * Promise that resolves on success, rejects on error (token expired, invalid, etc.) + * + * # Example (JavaScript) + * ```javascript + * // Normal refresh after login + * await client.refreshToken(); + * + * // Restore token from localStorage on page load + * const savedToken = localStorage.getItem('usp_token'); + * if (savedToken) { + * try { + * await client.refreshToken(savedToken); + * console.log('Token restored and validated'); + * } catch (e) { + * console.log('Token expired, need to re-login'); + * localStorage.removeItem('usp_token'); + * } + * } + * ``` + * @param {string | null} [token] * @returns {Promise} */ - refreshToken() { - const ret = wasm.uspclient_refreshToken(this.__wbg_ptr); + refreshToken(token) { + var ptr0 = isLikeNone(token) ? 0 : passStringToWasm0(token, wasm.__wbindgen_export, wasm.__wbindgen_export2); + var len0 = WASM_VECTOR_LEN; + const ret = wasm.uspclient_refreshToken(this.__wbg_ptr, ptr0, len0); return takeObject(ret); } /** @@ -833,7 +865,7 @@ function __wbg_get_imports() { const a = state0.a; state0.a = 0; try { - return __wasm_bindgen_func_elem_2268(a, state0.b, arg0, arg1); + return __wasm_bindgen_func_elem_2271(a, state0.b, arg0, arg1); } finally { state0.a = a; } @@ -967,7 +999,7 @@ function __wbg_get_imports() { }, __wbindgen_cast_0000000000000001: function(arg0, arg1) { // Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 206, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`. - const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_2257); + const ret = makeMutClosure(arg0, arg1, __wasm_bindgen_func_elem_2260); return addHeapObject(ret); }, __wbindgen_cast_0000000000000002: function(arg0) { @@ -994,10 +1026,10 @@ function __wbg_get_imports() { }; } -function __wasm_bindgen_func_elem_2257(arg0, arg1, arg2) { +function __wasm_bindgen_func_elem_2260(arg0, arg1, arg2) { try { const retptr = wasm.__wbindgen_add_to_stack_pointer(-16); - wasm.__wasm_bindgen_func_elem_2257(retptr, arg0, arg1, addHeapObject(arg2)); + wasm.__wasm_bindgen_func_elem_2260(retptr, arg0, arg1, addHeapObject(arg2)); var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true); var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true); if (r1) { @@ -1008,8 +1040,8 @@ function __wasm_bindgen_func_elem_2257(arg0, arg1, arg2) { } } -function __wasm_bindgen_func_elem_2268(arg0, arg1, arg2, arg3) { - wasm.__wasm_bindgen_func_elem_2268(arg0, arg1, addHeapObject(arg2), addHeapObject(arg3)); +function __wasm_bindgen_func_elem_2271(arg0, arg1, arg2, arg3) { + wasm.__wasm_bindgen_func_elem_2271(arg0, arg1, addHeapObject(arg2), addHeapObject(arg3)); } diff --git a/web/usp_client_bg.wasm b/web/usp_client_bg.wasm index deaa00f51..2d5597e58 100644 Binary files a/web/usp_client_bg.wasm and b/web/usp_client_bg.wasm differ