From b0a1292a78dc0745b6082471ca778d43a40dc9fe Mon Sep 17 00:00:00 2001 From: Hank Yu <52936029+HankYuLinksys@users.noreply.github.com> Date: Tue, 30 Jun 2026 10:51:42 +0800 Subject: [PATCH 01/56] docs(error-handling): consolidate guides + align constitution + fix shared-helper localization (#997) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(error-handling): add existing research and planning docs as baseline Snapshot the six error-handling & localization docs before reorganizing them into a best-practices guide. Committing first so subsequent deletions are diff-trackable. * docs(error-handling): strip general hardcoded-strings content Narrow the docs to error-handling scope only, ahead of writing the error-handling best-practices guide: - Delete 05 (844-entry general hardcoded-strings tracking table) — no error-handling content. - Slim 03 down to error-relevant parts only: i18n framework decision (no slang migration), ServiceError diagnostic-field groundwork, and the "Service-layer text is not localized" criterion. Removed the ~460-entry general string audit (sections 1/2/4/5/7 + appendix). - README: update 03/04 entries and status board (04 done via PR #953, drop the "non-error strings" follow-up line). - 04: drop the two bullets pointing at non-error hardcoded strings. * docs(error-handling): consolidate into reference + implementation guide Reorganize the error-handling docs into the two intended deliverables: - NEW error-handling-implementation-guide.md — the "how": per-layer (Service/Provider/View) patterns for implementing error handling in a USP feature, what to show vs. hide, localization, gotchas, and a pre-PR checklist. All examples verified against the current codebase (post PR #953), notably the two fetch-display patterns (ServiceErrorView for state.error pages, _buildError+localizeServiceError for AsyncValue pages) and the try/catch save path. - RENAME 01-usp-error-roundtrip-reference.md → usp-error-handling-reference.md (the "why": full round-trip background). Drop its section 4 (localization plan, now implemented) and refresh the progress notes. - DELETE 02/03/04 — their content is absorbed into the two docs above. - README: index just the two docs with a "how vs. why" reading guide. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(error-handling): reframe in reference voice, not progress-tracker These are reference docs, not a sprint board. Drop "done/todo/current work" status language in favor of describing the state of things: - Describe where implementations live (PR #953) rather than marking items "completed". - Correct the reference's section 3, which still described the pre-PR#953 state as the present (Provider stringifies '$e', View has no mapper). Reframe those as "pain points before PR #953" that motivated the refactor; point to the implementation guide for how the code reads now. - Update the flow diagram's Provider/View boxes to current behavior. - Phrase the GET 9999->9998 bug and the missing contract test as known issues / TODO, not checkboxes. - Drop volatile count snapshots ("36 guards") in favor of the rule. * fix(l10n): localize error in performUspMutation shared helper The shared dashboard-card mutation helper showed failures with a raw `'Error: $e'`, bypassing the central `localizeServiceError` mapper. This slipped past PR #953 because the raw string lived inside the helper, not at the call sites the audit grepped for. Route the caught error through `localizeServiceError` like the feature views do. This brings all 8 cards that use `performUspMutation` (internet_settings renew lease, local_network reservations, admin time, port_forwarding, wifi ×2, devices) into the localized error pipeline in one change. Verified: flutter analyze clean; internet_settings suite (127 tests) passes; no test asserted the old string. * docs(error-handling): cover performUspMutation + fix two inaccuracies - Add §3.3: dashboard cards trigger mutations via the shared performUspMutation helper, which now localizes failures internally — framed as a convenience entry point, NOT a third localization strategy. Note successMessage is shown as-is (caller must pass a loc()'d string). Add matching PR-checklist line. - Fix two claims found during codebase verification: - The _localizeFaultCode <-> _mapProtocolError sync reminder is one-directional in code; reworded accordingly. - The save-snackbar example showed only showFailedSnackBar; noted the ScaffoldMessenger+SnackBar variant some pages use — the API is secondary, the string must come from localizeServiceError. * docs(constitution): align Article XIII with post-PR#953 error handling Update the error-handling articles to match the current codebase and add the missing UI-layer principle. Keep it principle-level; details point to the implementation guide. - §13.2: ServiceError now carries diagnostic code/detail; drop the deleted OTP/admin subtype examples; note code/detail are diagnostic-only. - §13.4.2: performFetch stores the typed `error: e`, not `errorMessage: '$e'`. - §13.4.1: use an existing subtype (InvalidInputError) in the example. - §3.3.5: error classes extend the sealed ServiceError (no AuthError). - §13.1: UI layer localizes via the central localizeServiceError mapper. - Add §13.6 UI Layer Error Display — states the principle and links to doc/error-handling-localization/error-handling-implementation-guide.md. - Bump Last Amended to 2026-06-29 (version unchanged). * fix(l10n): localize success messages in card mutations The 7 hardcoded English successMessage strings passed to performUspMutation (and showRecoveryDialog) were the success-side counterpart to the error leak already fixed — all on dashboard cards / shell, missed by PR #953. - Reuse existing parametrized key for DHCP renew: leaseRenewed('DHCP'). - Add 6 new keys (reservationAdded, reservationDeleted, reconnectedToRouter, timeSettingsSaved, ruleAdded, channelUpdated), translated across all 26 locales. Translations follow each locale's existing style anchors (reservationReleased, wifiSettingsSaved, timezoneUpdated…) and noun usage (e.g. zh 信道 / zh_TW 通道 / ja チャネル for "channel"; per-locale "router"). - Update the 7 call sites to loc(context).xxx. Verified: 26/26 locales carry each key, all arb files valid JSON, flutter analyze clean, touched-feature tests pass (498). * docs(error-handling): translate the three docs to English The committed/shared docs must be English. Translate the README, the implementation guide, and the round-trip reference from Traditional Chinese to English in place. Pure translation — code blocks, identifiers, file paths, fault codes, ASCII diagrams, links, and section structure are preserved byte-for-byte; only prose was translated. Verified: 0 CJK characters remain, code fences balanced, sibling links resolve. --------- Co-authored-by: Claude Opus 4.8 (1M context) --- constitution.md | 86 +++- doc/error-handling-localization/README.md | 20 + .../error-handling-implementation-guide.md | 429 ++++++++++++++++ .../usp-error-handling-reference.md | 482 ++++++++++++++++++ lib/l10n/app_ar.arb | 6 + lib/l10n/app_da.arb | 6 + lib/l10n/app_de.arb | 6 + lib/l10n/app_el.arb | 6 + lib/l10n/app_en.arb | 6 + lib/l10n/app_es.arb | 6 + lib/l10n/app_es_ar.arb | 6 + lib/l10n/app_fi.arb | 6 + lib/l10n/app_fr.arb | 6 + lib/l10n/app_fr_ca.arb | 6 + lib/l10n/app_id.arb | 6 + lib/l10n/app_it.arb | 6 + lib/l10n/app_ja.arb | 6 + lib/l10n/app_ko.arb | 6 + lib/l10n/app_nb.arb | 6 + lib/l10n/app_nl.arb | 6 + lib/l10n/app_pl.arb | 6 + lib/l10n/app_pt.arb | 6 + lib/l10n/app_pt_pt.arb | 6 + lib/l10n/app_ru.arb | 6 + lib/l10n/app_sv.arb | 6 + lib/l10n/app_th.arb | 6 + lib/l10n/app_tr.arb | 6 + lib/l10n/app_vi.arb | 6 + lib/l10n/app_zh.arb | 6 + lib/l10n/app_zh_TW.arb | 6 + .../components/usp_mutation_helper.dart | 10 +- .../admin/cards/usp_time_settings_card.dart | 2 +- .../cards/usp_network_status_card.dart | 2 +- .../cards/usp_dhcp_reservations_card.dart | 4 +- .../cards/usp_port_forwarding_card.dart | 2 +- lib/page/shell/usp_dashboard_shell.dart | 2 +- .../cards/usp_wifi_status_card.dart | 2 +- 37 files changed, 1166 insertions(+), 31 deletions(-) create mode 100644 doc/error-handling-localization/README.md create mode 100644 doc/error-handling-localization/error-handling-implementation-guide.md create mode 100644 doc/error-handling-localization/usp-error-handling-reference.md diff --git a/constitution.md b/constitution.md index 10299bc43..8e214a9b2 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-29 ## 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,31 @@ 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` (state-based pages) or + call `localizeServiceError` inside `AsyncValue.when(error:)` (AsyncNotifier pages). +- **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-localization/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-localization/README.md b/doc/error-handling-localization/README.md new file mode 100644 index 000000000..cbfe348d5 --- /dev/null +++ b/doc/error-handling-localization/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-localization/error-handling-implementation-guide.md b/doc/error-handling-localization/error-handling-implementation-guide.md new file mode 100644 index 000000000..e1bcb584f --- /dev/null +++ b/doc/error-handling-localization/error-handling-implementation-guide.md @@ -0,0 +1,429 @@ +# 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 ultimately go through the same `localizeServiceError` in the View; only "where the error is stored" and "how the View displays it" differ (see §3). + +--- + +## 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 (two ways, corresponding to the two providers in §2) + +**(A) state.error pages → use the shared widget [`ServiceErrorView`](../../lib/components/views/service_error_view.dart)**: + +```dart +if (status.error != null) { + return ServiceErrorView( + error: status.error, + onRetry: () => ref.read(uspDmzProvider.notifier).fetch(forceRemote: true), + ); +} +``` + +`ServiceErrorView` already calls `localizeServiceError` internally; you don't need to translate it yourself. It displays: an error icon + the `loc(ctx).failedToLoadSettings` title + the localized detail + a retry button. + +**(B) AsyncValue pages → call `localizeServiceError` inside `.when(error:)`**: + +```dart +asyncState.when( + loading: () => const Center(child: AppLoader()), + error: (error, stack) => _buildError(context, ref, error), // error is Object + data: (state) => _buildContent(context, ref, state), +); + +Widget _buildError(BuildContext context, WidgetRef ref, Object error) { + return Center(child: Column(children: [ + AppIcon.font(Icons.error_outline, size: 48, color: ...error), + AppText.titleMedium(loc(context).failedToLoadSettings), + AppText.bodyMedium(localizeServiceError(context, error)), // ← localize + AppButton(label: loc(context).retry, onTap: () => ref.invalidate(uspAdminProvider)), + ])); +} +``` + +> **Why two ways?** `ServiceErrorView` takes a `ServiceError?` and is driven by `state.error`; `AsyncValue.when(error:)` gives an `Object error` (and retry uses `ref.invalidate` rather than `fetch(forceRemote)`). So AsyncValue pages keep their own small `_buildError`, but **the content must follow the snippet above and always go through `localizeServiceError`** — do not hardcode English yourself. +> `localizeServiceError`'s second parameter takes `Object` (defensively): a non-ServiceError falls back to `errorUnexpected`, so it is safe for AsyncValue to pass the `Object error` straight in. + +### 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'` | +| `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. +- **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 fetch failure goes through `ServiceErrorView` (state.error) or `_buildError + localizeServiceError` (AsyncValue). +- [ ] 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-localization/usp-error-handling-reference.md b/doc/error-handling-localization/usp-error-handling-reference.md new file mode 100644 index 000000000..07d17bb98 --- /dev/null +++ b/doc/error-handling-localization/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/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 384ed8797..495ca80aa 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -882,6 +882,12 @@ "renew": "تجديد", "requiredLabel": "(مطلوب)", "reservationReleased": "تم تحرير الحجز", + "reservationAdded": "تمت إضافة الحجز", + "reservationDeleted": "تم حذف الحجز", + "reconnectedToRouter": "تمت إعادة الاتصال بجهاز التوجيه", + "timeSettingsSaved": "تم حفظ إعدادات الوقت", + "ruleAdded": "تمت إضافة القاعدة", + "channelUpdated": "تم تحديث القناة", "reservations": "الحجوزات", "reserveIpAddress": "حجز عنوان IP", "reserved": "محجوز", diff --git a/lib/l10n/app_da.arb b/lib/l10n/app_da.arb index 3df971451..053da2cec 100644 --- a/lib/l10n/app_da.arb +++ b/lib/l10n/app_da.arb @@ -882,6 +882,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", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index f7f76e536..336c0a407 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -882,6 +882,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", diff --git a/lib/l10n/app_el.arb b/lib/l10n/app_el.arb index 1df8338ac..487c5fb67 100644 --- a/lib/l10n/app_el.arb +++ b/lib/l10n/app_el.arb @@ -882,6 +882,12 @@ "renew": "Ανανέωση", "requiredLabel": "(Υποχρεωτικό)", "reservationReleased": "Η δέσμευση αποδεσμεύτηκε", + "reservationAdded": "Η δέσμευση προστέθηκε", + "reservationDeleted": "Η δέσμευση διαγράφηκε", + "reconnectedToRouter": "Έγινε επανασύνδεση στον δρομολογητή", + "timeSettingsSaved": "Οι ρυθμίσεις ώρας αποθηκεύτηκαν", + "ruleAdded": "Ο κανόνας προστέθηκε", + "channelUpdated": "Το κανάλι ενημερώθηκε", "reservations": "Δεσμεύσεις", "reserveIpAddress": "Δέσμευση διεύθυνσης IP", "reserved": "Δεσμευμένο", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index c3c5477a1..4fd3f3a62 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -703,6 +703,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})", @@ -1190,6 +1191,7 @@ "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,6 +1213,7 @@ "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", @@ -1466,6 +1469,8 @@ }, "ipAddressReserved": "IP address reserved", "reservationReleased": "Reservation released", + "reservationAdded": "Reservation added", + "reservationDeleted": "Reservation deleted", "viaNode": "via {node}", "@viaNode": { "placeholders": { @@ -1476,6 +1481,7 @@ }, "connectingToRouter": "Connecting to router...", "reconnecting": "Reconnecting...", + "reconnectedToRouter": "Reconnected to router", "realTimeConnectionLost": "Real-time connection lost", "disconnected": "Disconnected", "reconnect": "Reconnect", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index ff4485b24..c0aa10c67 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -882,6 +882,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", diff --git a/lib/l10n/app_es_ar.arb b/lib/l10n/app_es_ar.arb index 59b7fa350..bc86222d8 100644 --- a/lib/l10n/app_es_ar.arb +++ b/lib/l10n/app_es_ar.arb @@ -882,6 +882,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", diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb index 229e30001..7864918e0 100644 --- a/lib/l10n/app_fi.arb +++ b/lib/l10n/app_fi.arb @@ -882,6 +882,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", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index c411ce3a8..3315a3af5 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -882,6 +882,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é", diff --git a/lib/l10n/app_fr_ca.arb b/lib/l10n/app_fr_ca.arb index 03cf082a4..c6728effa 100644 --- a/lib/l10n/app_fr_ca.arb +++ b/lib/l10n/app_fr_ca.arb @@ -882,6 +882,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é", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 1b4682f4c..d6f353338 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -882,6 +882,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", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 9fdb1b64d..b7011b3d0 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -882,6 +882,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", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 97c4c51b3..d6029ab0c 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -882,6 +882,12 @@ "renew": "更新", "requiredLabel": "(必須)", "reservationReleased": "予約を解放しました", + "reservationAdded": "予約を追加しました", + "reservationDeleted": "予約を削除しました", + "reconnectedToRouter": "ルーターに再接続しました", + "timeSettingsSaved": "時刻設定を保存しました", + "ruleAdded": "ルールを追加しました", + "channelUpdated": "チャネルを更新しました", "reservations": "予約", "reserveIpAddress": "IP アドレスを予約", "reserved": "予約済み", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 5625399be..9eb8615f3 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -882,6 +882,12 @@ "renew": "갱신", "requiredLabel": "(필수)", "reservationReleased": "예약이 해제됨", + "reservationAdded": "예약이 추가됨", + "reservationDeleted": "예약이 삭제됨", + "reconnectedToRouter": "라우터에 다시 연결됨", + "timeSettingsSaved": "시간 설정이 저장됨", + "ruleAdded": "규칙이 추가됨", + "channelUpdated": "채널이 업데이트됨", "reservations": "예약", "reserveIpAddress": "IP 주소 예약", "reserved": "예약됨", diff --git a/lib/l10n/app_nb.arb b/lib/l10n/app_nb.arb index fb18fd758..3b5ab81f7 100644 --- a/lib/l10n/app_nb.arb +++ b/lib/l10n/app_nb.arb @@ -882,6 +882,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", diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index a6df11a85..72b3aac61 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -882,6 +882,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", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index daf07e099..bdccf23cc 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -882,6 +882,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", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 4f062757c..0e775a0b3 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -882,6 +882,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", diff --git a/lib/l10n/app_pt_pt.arb b/lib/l10n/app_pt_pt.arb index 1d637511e..feb058e35 100644 --- a/lib/l10n/app_pt_pt.arb +++ b/lib/l10n/app_pt_pt.arb @@ -882,6 +882,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", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 6ed6cce40..2fa5f0c84 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -882,6 +882,12 @@ "renew": "Обновить", "requiredLabel": "(Обязательно)", "reservationReleased": "Резервирование освобождено", + "reservationAdded": "Резервирование добавлено", + "reservationDeleted": "Резервирование удалено", + "reconnectedToRouter": "Повторное подключение к маршрутизатору выполнено", + "timeSettingsSaved": "Настройки времени сохранены", + "ruleAdded": "Правило добавлено", + "channelUpdated": "Канал обновлен", "reservations": "Резервирования", "reserveIpAddress": "Зарезервировать IP-адрес", "reserved": "Зарезервировано", diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index 39d8b295c..fa483b7c0 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -882,6 +882,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", diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index b94e314e1..95e23bdec 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -882,6 +882,12 @@ "renew": "ต่ออายุ", "requiredLabel": "(จำเป็น)", "reservationReleased": "ปล่อยการสำรองแล้ว", + "reservationAdded": "เพิ่มการสำรองแล้ว", + "reservationDeleted": "ลบการสำรองแล้ว", + "reconnectedToRouter": "เชื่อมต่อกับเราเตอร์อีกครั้งแล้ว", + "timeSettingsSaved": "บันทึกการตั้งค่าเวลาแล้ว", + "ruleAdded": "เพิ่มกฎแล้ว", + "channelUpdated": "อัปเดตช่องสัญญาณแล้ว", "reservations": "การสำรอง", "reserveIpAddress": "สำรองที่อยู่ IP", "reserved": "สำรองไว้", diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index 622ee4918..75787f90f 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -882,6 +882,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ı", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 84a042efe..9b2db3d87 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -882,6 +882,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", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 2d39b05fb..7014f68fd 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -882,6 +882,12 @@ "renew": "续订", "requiredLabel": "(必填)", "reservationReleased": "保留已释放", + "reservationAdded": "保留已添加", + "reservationDeleted": "保留已删除", + "reconnectedToRouter": "已重新连接到路由器", + "timeSettingsSaved": "时间设置已保存", + "ruleAdded": "规则已添加", + "channelUpdated": "信道已更新", "reservations": "保留", "reserveIpAddress": "保留 IP 地址", "reserved": "已保留", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 7f051aec8..76d818fb7 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -882,6 +882,12 @@ "renew": "更新", "requiredLabel": "(必填)", "reservationReleased": "已釋放保留", + "reservationAdded": "已新增保留", + "reservationDeleted": "已刪除保留", + "reconnectedToRouter": "已重新連線到路由器", + "timeSettingsSaved": "已儲存時間設定", + "ruleAdded": "已新增規則", + "channelUpdated": "已更新通道", "reservations": "保留", "reserveIpAddress": "保留 IP 位址", "reserved": "已保留", 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/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/internet_settings/cards/usp_network_status_card.dart b/lib/page/internet_settings/cards/usp_network_status_card.dart index a35ac33fb..f8c01744e 100644 --- a/lib/page/internet_settings/cards/usp_network_status_card.dart +++ b/lib/page/internet_settings/cards/usp_network_status_card.dart @@ -38,7 +38,7 @@ class UspNetworkStatusCard extends ConsumerWidget { mutation: () => ref .read(uspInternetSettingsProvider.notifier) .renewDhcpLease(), - successMessage: 'DHCP lease renewed', + successMessage: loc(context).leaseRenewed('DHCP'), ), ) : null, 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..12800a5bd 100644 --- a/lib/page/local_network/cards/usp_dhcp_reservations_card.dart +++ b/lib/page/local_network/cards/usp_dhcp_reservations_card.dart @@ -165,7 +165,7 @@ class UspDhcpReservationsCard extends ConsumerWidget { ip: result.ip, enable: result.enable, ), - successMessage: 'Reservation added', + successMessage: loc(context).reservationAdded, ); } @@ -195,7 +195,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/port_forwarding/cards/usp_port_forwarding_card.dart b/lib/page/port_forwarding/cards/usp_port_forwarding_card.dart index 819c6d22b..31e2162e7 100644 --- a/lib/page/port_forwarding/cards/usp_port_forwarding_card.dart +++ b/lib/page/port_forwarding/cards/usp_port_forwarding_card.dart @@ -165,7 +165,7 @@ class UspPortForwardingCard extends ConsumerWidget { description: result.description, enabled: result.enabled, ), - successMessage: 'Rule added', + successMessage: loc(context).ruleAdded, ); } } 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/wifi_settings/cards/usp_wifi_status_card.dart b/lib/page/wifi_settings/cards/usp_wifi_status_card.dart index 24cf5b61f..4da701920 100644 --- a/lib/page/wifi_settings/cards/usp_wifi_status_card.dart +++ b/lib/page/wifi_settings/cards/usp_wifi_status_card.dart @@ -173,7 +173,7 @@ class UspWifiStatusCard extends ConsumerWidget { channel: result.channel, autoChannel: result.autoChannel, ), - successMessage: 'Channel updated', + successMessage: loc(context).channelUpdated, ); } From 9c185ff185b71247c35ed0135a8b201d1094082e Mon Sep 17 00:00:00 2001 From: Austin Chang Date: Mon, 29 Jun 2026 13:26:34 +0800 Subject: [PATCH 02/56] fix(dashboard): resolve card clipping and add mascot outside dismiss - Remove ClipRect from dashboard cards to prevent shadow/border truncation - Add dismissible barrier to all mascot interactive dialogs - Bump ui_kit_library to v2.26.0 Co-Authored-By: Claude Opus 4.5 --- lib/page/dashboard/mascot/dashboard_dialog_provider.dart | 5 +++++ lib/page/dashboard/mascot/health_dialog_provider.dart | 6 ++++++ lib/page/dashboard/views/usp_sliver_dashboard_view.dart | 6 +++--- pubspec.yaml | 4 ++-- 4 files changed, 16 insertions(+), 5 deletions(-) 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_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/views/usp_sliver_dashboard_view.dart b/lib/page/dashboard/views/usp_sliver_dashboard_view.dart index c6782273d..f405fd51d 100644 --- a/lib/page/dashboard/views/usp_sliver_dashboard_view.dart +++ b/lib/page/dashboard/views/usp_sliver_dashboard_view.dart @@ -578,9 +578,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/pubspec.yaml b/pubspec.yaml index a2cc4ab38..c547cf12f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -59,11 +59,11 @@ dependencies: ui_kit_library: git: url: https://github.com/linksys/privacyGUI-UI-kit.git - ref: v2.25.1 + ref: v2.26.0 generative_ui: git: url: https://github.com/linksys/privacyGUI-UI-kit.git - ref: v2.25.1 + ref: v2.26.0 path: generative_ui flutter_blue_plus: ^1.4.0 crypto: ^3.0.2 From 4075bfd45a4288858454a00db99f29d1aafe00cc Mon Sep 17 00:00:00 2001 From: Hank Yu Date: Tue, 30 Jun 2026 15:11:19 +0800 Subject: [PATCH 03/56] feat(components): add optional secondary action to ServiceErrorView Add `secondaryLabel` + `onSecondary` (both optional, null by default) so a page that cannot load can offer an escape hatch (e.g. "Log out") below the retry button. Behavior is unchanged when not provided. Covered by two new widget tests. --- lib/components/views/service_error_view.dart | 15 ++++++++++ .../views/service_error_view_test.dart | 30 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/lib/components/views/service_error_view.dart b/lib/components/views/service_error_view.dart index 053bdba4b..1725ad254 100644 --- a/lib/components/views/service_error_view.dart +++ b/lib/components/views/service_error_view.dart @@ -16,10 +16,18 @@ class ServiceErrorView extends StatelessWidget { /// Called when the user taps retry (e.g. re-fetch with forceRemote). final VoidCallback onRetry; + /// 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 to show it. + final String? secondaryLabel; + final VoidCallback? onSecondary; + const ServiceErrorView({ super.key, required this.error, required this.onRetry, + this.secondaryLabel, + this.onSecondary, }); @override @@ -43,6 +51,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/test/components/views/service_error_view_test.dart b/test/components/views/service_error_view_test.dart index eb3a4f896..e7dc1f9bc 100644 --- a/test/components/views/service_error_view_test.dart +++ b/test/components/views/service_error_view_test.dart @@ -73,5 +73,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); + }); }); } From c974dc9cb30d4ef39d7be120e135b5aa1cddcaf0 Mon Sep 17 00:00:00 2001 From: Hank Yu Date: Tue, 30 Jun 2026 15:12:06 +0800 Subject: [PATCH 04/56] refactor(l10n): migrate AsyncValue error pages to shared ServiceErrorView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the per-page private `_buildError` / inline error widgets on the AsyncValue (AsyncNotifier) pages with the shared `ServiceErrorView`, narrowing `Object error` via `error is ServiceError ? error : null`. This unifies fetch-failure display across both page architectures (state.error pages already used ServiceErrorView). - system_log, instant_privacy, admin: straight swap (retry = ref.invalidate) - dashboard: retry = notifier.refreshAll(); keeps its "Log out" escape hatch via the new ServiceErrorView secondary action - topology: previously an inline error with a hardcoded `unableToLoadTopology` title and no localized detail — now gets the localized detail for free. Removed the now-orphaned `unableToLoadTopology` key from all 26 locales. Verified: flutter analyze clean on changed files; affected non-golden tests pass; all 26 .arb files valid JSON. --- lib/l10n/app_ar.arb | 1 - lib/l10n/app_da.arb | 1 - lib/l10n/app_de.arb | 1 - lib/l10n/app_el.arb | 1 - lib/l10n/app_en.arb | 1 - lib/l10n/app_es.arb | 1 - lib/l10n/app_es_ar.arb | 1 - lib/l10n/app_fi.arb | 1 - lib/l10n/app_fr.arb | 1 - lib/l10n/app_fr_ca.arb | 1 - lib/l10n/app_id.arb | 1 - lib/l10n/app_it.arb | 1 - lib/l10n/app_ja.arb | 1 - lib/l10n/app_ko.arb | 1 - lib/l10n/app_nb.arb | 1 - lib/l10n/app_nl.arb | 1 - lib/l10n/app_pl.arb | 1 - lib/l10n/app_pt.arb | 1 - lib/l10n/app_pt_pt.arb | 1 - lib/l10n/app_ru.arb | 1 - lib/l10n/app_sv.arb | 1 - lib/l10n/app_th.arb | 1 - lib/l10n/app_tr.arb | 1 - lib/l10n/app_vi.arb | 1 - lib/l10n/app_zh.arb | 1 - lib/l10n/app_zh_TW.arb | 1 - lib/page/admin/views/usp_admin_view.dart | 28 +++---------- .../dashboard/views/usp_dashboard_view.dart | 39 +++++-------------- .../views/instant_privacy_view.dart | 28 +++---------- .../system_log/views/usp_system_log_view.dart | 29 +++----------- .../topology/views/usp_topology_view.dart | 17 +++----- 31 files changed, 33 insertions(+), 134 deletions(-) diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 495ca80aa..5087ebd02 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -1017,7 +1017,6 @@ "unableToLoadApps": "تعذّر تحميل التطبيقات", "unableToLoadDiagnostics": "تعذّر تحميل التشخيص", "unableToLoadSpeedTest": "تعذّر تحميل اختبار السرعة", - "unableToLoadTopology": "تعذّر تحميل الطوبولوجيا", "unchangedLabel": "(لم يتغير)", "unknownWidget": "أداة غير معروفة: {id}", "unnamed": "(بدون اسم)", diff --git a/lib/l10n/app_da.arb b/lib/l10n/app_da.arb index 053da2cec..f78cd8a6e 100644 --- a/lib/l10n/app_da.arb +++ b/lib/l10n/app_da.arb @@ -1017,7 +1017,6 @@ "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)", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 336c0a407..d2d275b53 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1017,7 +1017,6 @@ "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)", diff --git a/lib/l10n/app_el.arb b/lib/l10n/app_el.arb index 487c5fb67..524016bb9 100644 --- a/lib/l10n/app_el.arb +++ b/lib/l10n/app_el.arb @@ -1017,7 +1017,6 @@ "unableToLoadApps": "Δεν είναι δυνατή η φόρτωση των εφαρμογών", "unableToLoadDiagnostics": "Δεν είναι δυνατή η φόρτωση των διαγνωστικών", "unableToLoadSpeedTest": "Δεν είναι δυνατή η φόρτωση του τεστ ταχύτητας", - "unableToLoadTopology": "Δεν είναι δυνατή η φόρτωση της τοπολογίας", "unchangedLabel": "(Αμετάβλητο)", "unknownWidget": "Άγνωστο widget: {id}", "unnamed": "(χωρίς όνομα)", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 4fd3f3a62..9ba8b9b31 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1640,7 +1640,6 @@ "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", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index c0aa10c67..63c0b5021 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1017,7 +1017,6 @@ "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)", diff --git a/lib/l10n/app_es_ar.arb b/lib/l10n/app_es_ar.arb index bc86222d8..bfab6a8cf 100644 --- a/lib/l10n/app_es_ar.arb +++ b/lib/l10n/app_es_ar.arb @@ -1017,7 +1017,6 @@ "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)", diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb index 7864918e0..56c221e78 100644 --- a/lib/l10n/app_fi.arb +++ b/lib/l10n/app_fi.arb @@ -1017,7 +1017,6 @@ "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)", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 3315a3af5..f2593ae0a 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1017,7 +1017,6 @@ "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)", diff --git a/lib/l10n/app_fr_ca.arb b/lib/l10n/app_fr_ca.arb index c6728effa..b887e58dd 100644 --- a/lib/l10n/app_fr_ca.arb +++ b/lib/l10n/app_fr_ca.arb @@ -1017,7 +1017,6 @@ "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)", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index d6f353338..896e82994 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -1017,7 +1017,6 @@ "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)", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index b7011b3d0..49ba9b16f 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -1017,7 +1017,6 @@ "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)", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index d6029ab0c..05adc042b 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -1017,7 +1017,6 @@ "unableToLoadApps": "アプリを読み込めません", "unableToLoadDiagnostics": "診断を読み込めません", "unableToLoadSpeedTest": "速度テストを読み込めません", - "unableToLoadTopology": "トポロジーを読み込めません", "unchangedLabel": "(変更なし)", "unknownWidget": "不明なウィジェット: {id}", "unnamed": "(名前なし)", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 9eb8615f3..fd92e3aa3 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -1017,7 +1017,6 @@ "unableToLoadApps": "앱을 로드할 수 없습니다", "unableToLoadDiagnostics": "진단을 로드할 수 없습니다", "unableToLoadSpeedTest": "속도 테스트를 로드할 수 없습니다", - "unableToLoadTopology": "토폴로지를 로드할 수 없습니다", "unchangedLabel": "(변경되지 않음)", "unknownWidget": "알 수 없는 위젯: {id}", "unnamed": "(이름 없음)", diff --git a/lib/l10n/app_nb.arb b/lib/l10n/app_nb.arb index 3b5ab81f7..da4a42fc5 100644 --- a/lib/l10n/app_nb.arb +++ b/lib/l10n/app_nb.arb @@ -1017,7 +1017,6 @@ "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)", diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 72b3aac61..9f931753c 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -1017,7 +1017,6 @@ "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)", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index bdccf23cc..e942fdb79 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -1017,7 +1017,6 @@ "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)", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 0e775a0b3..8064a8fbc 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -1017,7 +1017,6 @@ "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)", diff --git a/lib/l10n/app_pt_pt.arb b/lib/l10n/app_pt_pt.arb index feb058e35..84c7aab3a 100644 --- a/lib/l10n/app_pt_pt.arb +++ b/lib/l10n/app_pt_pt.arb @@ -1017,7 +1017,6 @@ "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)", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 2fa5f0c84..202d66a44 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1017,7 +1017,6 @@ "unableToLoadApps": "Не удалось загрузить приложения", "unableToLoadDiagnostics": "Не удалось загрузить диагностику", "unableToLoadSpeedTest": "Не удалось загрузить тест скорости", - "unableToLoadTopology": "Не удалось загрузить топологию", "unchangedLabel": "(Без изменений)", "unknownWidget": "Неизвестный виджет: {id}", "unnamed": "(без имени)", diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index fa483b7c0..291624ba6 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -1017,7 +1017,6 @@ "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)", diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 95e23bdec..61fd7bc89 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -1017,7 +1017,6 @@ "unableToLoadApps": "ไม่สามารถโหลดแอปได้", "unableToLoadDiagnostics": "ไม่สามารถโหลดการวินิจฉัยได้", "unableToLoadSpeedTest": "ไม่สามารถโหลดการทดสอบความเร็วได้", - "unableToLoadTopology": "ไม่สามารถโหลดโทโพโลยีได้", "unchangedLabel": "(ไม่เปลี่ยนแปลง)", "unknownWidget": "วิดเจ็ตที่ไม่รู้จัก: {id}", "unnamed": "(ไม่มีชื่อ)", diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index 75787f90f..2e467a764 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -1017,7 +1017,6 @@ "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)", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 9b2db3d87..1992d1b6f 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -1017,7 +1017,6 @@ "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)", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 7014f68fd..2bbc03ec2 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1017,7 +1017,6 @@ "unableToLoadApps": "无法加载应用", "unableToLoadDiagnostics": "无法加载诊断", "unableToLoadSpeedTest": "无法加载速度测试", - "unableToLoadTopology": "无法加载拓扑", "unchangedLabel": "(未更改)", "unknownWidget": "未知小组件:{id}", "unnamed": "(未命名)", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 76d818fb7..b73b19c0d 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -1017,7 +1017,6 @@ "unableToLoadApps": "無法載入 App", "unableToLoadDiagnostics": "無法載入診斷", "unableToLoadSpeedTest": "無法載入速度測試", - "unableToLoadTopology": "無法載入拓撲", "unchangedLabel": "(未變更)", "unknownWidget": "未知的小工具:{id}", "unnamed": "(未命名)", diff --git a/lib/page/admin/views/usp_admin_view.dart b/lib/page/admin/views/usp_admin_view.dart index 611ccbe8b..e0900b060 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,16 @@ class UspAdminView extends ConsumerWidget { child: AppLoader(), ), ), - error: (error, stack) => _buildError(childContext, ref, error), + error: (error, stack) => ServiceErrorView( + error: error is ServiceError ? error : null, + 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/dashboard/views/usp_dashboard_view.dart b/lib/page/dashboard/views/usp_dashboard_view.dart index 3d5c82744..4f5b5ca3b 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,14 @@ 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, + onRetry: () => ref + .read(dashboardOrchestratorProvider.notifier) + .refreshAll(), + secondaryLabel: loc(context).logout, + onSecondary: () => _logout(context, ref), + ), data: (_) => const UspSliverDashboardView(), ), ), @@ -62,33 +70,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/instant_privacy/views/instant_privacy_view.dart b/lib/page/instant_privacy/views/instant_privacy_view.dart index 3ccb848cb..496e26fdd 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,34 +37,16 @@ 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, + 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, 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..9e829d04d 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,16 @@ 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, + 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/views/usp_topology_view.dart b/lib/page/topology/views/usp_topology_view.dart index f759cf145..ef47899ec 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,18 +46,9 @@ 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, + onRetry: () => ref.invalidate(devicesDataProvider), ), data: (data) { final sysInfo = ref.read(systemInfoDataProvider).valueOrNull?.model; From 5bbd315ff9e93a31814a1d43188f12da686736a7 Mon Sep 17 00:00:00 2001 From: Hank Yu Date: Tue, 30 Jun 2026 15:38:52 +0800 Subject: [PATCH 05/56] refactor(l10n): migrate diagnostics error pages to ServiceErrorView; localize apps error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Continue the AsyncValue error-display migration (rounds 2-3 of the 9 targets): - speed_test_view, diagnostic_manual_tools_view, usp_speed_test_card: replace private error widgets (which showed `error.toString()` or a hardcoded key) with the shared `ServiceErrorView`, narrowing `Object error` via `error is ServiceError ? error : null`. The diagnostics providers throw ServiceError/TimeoutError, so failures now localize. - Removed the now-orphaned keys: unableToLoadSpeedTest, unableToLoadDiagnostics, errorLoadingSpeedTest (all 26 locales). apps page is intentionally NOT migrated: it fetches lighttpd static JSON (not USP/TR-181) and throws plain `Exception`, not `ServiceError`, so `ServiceErrorView` would only show a generic title. It keeps its own error widget but no longer surfaces the raw exception — shows the localized `unableToLoadApps` instead. Also aligned `unableToLoadApps` to the plural "Apps" brand word for locales that use that spelling (en/da/de/es_ar/nl/pt/it/zh_TW). Verified: flutter analyze clean on changed files; diagnostics + apps tests pass (234); all 26 .arb valid JSON; no dangling key references. --- lib/l10n/app_ar.arb | 5 +--- lib/l10n/app_da.arb | 5 +--- lib/l10n/app_de.arb | 5 +--- lib/l10n/app_el.arb | 5 +--- lib/l10n/app_en.arb | 5 +--- lib/l10n/app_es.arb | 5 +--- lib/l10n/app_es_ar.arb | 5 +--- lib/l10n/app_fi.arb | 5 +--- lib/l10n/app_fr.arb | 5 +--- lib/l10n/app_fr_ca.arb | 5 +--- lib/l10n/app_id.arb | 5 +--- lib/l10n/app_it.arb | 5 +--- lib/l10n/app_ja.arb | 5 +--- lib/l10n/app_ko.arb | 5 +--- lib/l10n/app_nb.arb | 5 +--- lib/l10n/app_nl.arb | 5 +--- lib/l10n/app_pl.arb | 5 +--- lib/l10n/app_pt.arb | 5 +--- lib/l10n/app_pt_pt.arb | 5 +--- lib/l10n/app_ru.arb | 5 +--- lib/l10n/app_sv.arb | 5 +--- lib/l10n/app_th.arb | 5 +--- lib/l10n/app_tr.arb | 5 +--- lib/l10n/app_vi.arb | 5 +--- lib/l10n/app_zh.arb | 5 +--- lib/l10n/app_zh_TW.arb | 5 +--- lib/page/apps/views/usp_apps_view.dart | 10 ++++--- .../cards/usp_speed_test_card.dart | 26 ++++------------- .../views/speed_test_view.dart | 28 ++++--------------- .../widgets/diagnostic_manual_tools_view.dart | 28 ++++--------------- 30 files changed, 50 insertions(+), 172 deletions(-) diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 5087ebd02..0b8257588 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -597,7 +597,6 @@ "errorInvalidCredentials": "اسم المستخدم أو كلمة المرور غير صحيحة.", "errorInvalidInput": "القيمة المُدخلة غير صالحة. يرجى التحقق والمحاولة مرة أخرى.", "errorInvalidSessionToken": "جلستك غير صالحة. يرجى تسجيل الدخول مرة أخرى.", - "errorLoadingSpeedTest": "خطأ في تحميل اختبار السرعة", "errorNetwork": "خطأ في الشبكة. يرجى التحقق من اتصالك والمحاولة مرة أخرى.", "errorNotAuthenticated": "لم تسجّل الدخول. يرجى تسجيل الدخول والمحاولة مرة أخرى.", "errorResourceNotFound": "تعذّر العثور على الإعداد المطلوب على جهاز التوجيه.", @@ -763,6 +762,7 @@ "noAdvancedWifiSettings": "لا توجد إعدادات WiFi متقدمة متاحة لهذا الجهاز.", "noAnswersReturned": "لم تُرجَع أي إجابات.", "noAppsInstalled": "لا توجد تطبيقات مثبتة على جهاز التوجيه هذا", + "unableToLoadApps": "تعذّر تحميل التطبيقات", "noClients": "لا يوجد عملاء", "noDeviceActivityRecorded": "لم يُسجّل أي نشاط للجهاز", "noDevicesCurrentlyConnected": "لا توجد أجهزة متصلة حاليًا.", @@ -1014,9 +1014,6 @@ "type": "النوع", "typeAMessage": "اكتب رسالة...", "unableToGatherDeviceInfo": "تعذّر جمع معلومات الجهاز", - "unableToLoadApps": "تعذّر تحميل التطبيقات", - "unableToLoadDiagnostics": "تعذّر تحميل التشخيص", - "unableToLoadSpeedTest": "تعذّر تحميل اختبار السرعة", "unchangedLabel": "(لم يتغير)", "unknownWidget": "أداة غير معروفة: {id}", "unnamed": "(بدون اسم)", diff --git a/lib/l10n/app_da.arb b/lib/l10n/app_da.arb index f78cd8a6e..e32de1ee1 100644 --- a/lib/l10n/app_da.arb +++ b/lib/l10n/app_da.arb @@ -596,7 +596,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 +762,7 @@ "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", "noClients": "Ingen klienter", "noDeviceActivityRecorded": "Ingen enhedsaktivitet registreret", "noDevicesCurrentlyConnected": "Ingen enheder er tilsluttet i øjeblikket.", @@ -1014,9 +1014,6 @@ "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", "unchangedLabel": "(Uændret)", "unknownWidget": "Ukendt widget: {id}", "unnamed": "(unavngivet)", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index d2d275b53..97164ce7c 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -596,7 +596,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 +762,7 @@ "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", "noClients": "Keine Clients", "noDeviceActivityRecorded": "Keine Geräteaktivität aufgezeichnet", "noDevicesCurrentlyConnected": "Derzeit sind keine Geräte verbunden.", @@ -1014,9 +1014,6 @@ "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", "unchangedLabel": "(Unverändert)", "unknownWidget": "Unbekanntes Widget: {id}", "unnamed": "(unbenannt)", diff --git a/lib/l10n/app_el.arb b/lib/l10n/app_el.arb index 524016bb9..33563695b 100644 --- a/lib/l10n/app_el.arb +++ b/lib/l10n/app_el.arb @@ -594,7 +594,6 @@ "errorInvalidCredentials": "Λανθασμένο όνομα χρήστη ή κωδικός πρόσβασης.", "errorInvalidInput": "Η τιμή που εισαγάγατε δεν είναι έγκυρη. Ελέγξτε και δοκιμάστε ξανά.", "errorInvalidSessionToken": "Η συνεδρία σας δεν είναι έγκυρη. Συνδεθείτε ξανά.", - "errorLoadingSpeedTest": "Σφάλμα φόρτωσης του τεστ ταχύτητας", "errorNetwork": "Σφάλμα δικτύου. Ελέγξτε τη σύνδεσή σας και δοκιμάστε ξανά.", "errorNotAuthenticated": "Δεν έχετε συνδεθεί. Συνδεθείτε και δοκιμάστε ξανά.", "errorResourceNotFound": "Η ζητούμενη ρύθμιση δεν βρέθηκε στον δρομολογητή.", @@ -763,6 +762,7 @@ "noAdvancedWifiSettings": "Δεν υπάρχουν διαθέσιμες ρυθμίσεις WiFi για προχωρημένους για αυτήν τη συσκευή.", "noAnswersReturned": "Δεν επιστράφηκαν απαντήσεις.", "noAppsInstalled": "Δεν υπάρχουν εγκατεστημένες εφαρμογές σε αυτόν τον δρομολογητή", + "unableToLoadApps": "Δεν είναι δυνατή η φόρτωση των εφαρμογών", "noClients": "Δεν υπάρχουν πελάτες", "noDeviceActivityRecorded": "Δεν καταγράφηκε δραστηριότητα συσκευής", "noDevicesCurrentlyConnected": "Δεν υπάρχουν συσκευές συνδεδεμένες αυτή τη στιγμή.", @@ -1014,9 +1014,6 @@ "type": "Τύπος", "typeAMessage": "Πληκτρολογήστε ένα μήνυμα...", "unableToGatherDeviceInfo": "Δεν είναι δυνατή η συλλογή πληροφοριών συσκευής", - "unableToLoadApps": "Δεν είναι δυνατή η φόρτωση των εφαρμογών", - "unableToLoadDiagnostics": "Δεν είναι δυνατή η φόρτωση των διαγνωστικών", - "unableToLoadSpeedTest": "Δεν είναι δυνατή η φόρτωση του τεστ ταχύτητας", "unchangedLabel": "(Αμετάβλητο)", "unknownWidget": "Άγνωστο widget: {id}", "unnamed": "(χωρίς όνομα)", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 9ba8b9b31..dd1f9662e 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1040,9 +1040,6 @@ "noAnswersReturned": "No answers returned.", "ipsColumn": "IPs", "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", @@ -1608,6 +1605,7 @@ "networkTopology": "Network Topology", "newPassword": "New Password", "noAppsInstalled": "No apps installed on this router", + "unableToLoadApps": "Unable to load Apps", "routerPasswordRuleNoConsecutive": "No consecutive identical characters", "noDimensions": "No dimensions", "noLogFilesAvailable": "No log files available on this router", @@ -1639,7 +1637,6 @@ "badgeUser": "USER", "uspConsole": "USP Console", "uspDashboard": "USP Dashboard", - "unableToLoadApps": "Unable to load apps", "routerPasswordRuleUpperLower": "Upper and lower case letters", "validatingSession": "Validating session...", "viewDevices": "View Devices", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 63c0b5021..e9dacfa13 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -596,7 +596,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 +762,7 @@ "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", "noClients": "Sin clientes", "noDeviceActivityRecorded": "No se ha registrado actividad de dispositivos", "noDevicesCurrentlyConnected": "No hay dispositivos conectados actualmente.", @@ -1014,9 +1014,6 @@ "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", "unchangedLabel": "(Sin cambios)", "unknownWidget": "Widget desconocido: {id}", "unnamed": "(sin nombre)", diff --git a/lib/l10n/app_es_ar.arb b/lib/l10n/app_es_ar.arb index bfab6a8cf..a0d85ab71 100644 --- a/lib/l10n/app_es_ar.arb +++ b/lib/l10n/app_es_ar.arb @@ -596,7 +596,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 +762,7 @@ "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", "noClients": "Sin clientes", "noDeviceActivityRecorded": "No se registró actividad de dispositivos", "noDevicesCurrentlyConnected": "No hay dispositivos conectados actualmente.", @@ -1014,9 +1014,6 @@ "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", "unchangedLabel": "(Sin cambios)", "unknownWidget": "Widget desconocido: {id}", "unnamed": "(sin nombre)", diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb index 56c221e78..153bace22 100644 --- a/lib/l10n/app_fi.arb +++ b/lib/l10n/app_fi.arb @@ -594,7 +594,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 +762,7 @@ "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", "noClients": "Ei asiakaslaitteita", "noDeviceActivityRecorded": "Laitetoimintaa ei ole kirjattu", "noDevicesCurrentlyConnected": "Yhtään laitetta ei ole tällä hetkellä yhdistettynä.", @@ -1014,9 +1014,6 @@ "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", "unchangedLabel": "(Muuttumaton)", "unknownWidget": "Tuntematon widget: {id}", "unnamed": "(nimetön)", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index f2593ae0a..598c3a03a 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -596,7 +596,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 +762,7 @@ "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", "noClients": "Aucun client", "noDeviceActivityRecorded": "Aucune activité de périphérique enregistrée", "noDevicesCurrentlyConnected": "Aucun périphérique n'est actuellement connecté.", @@ -1014,9 +1014,6 @@ "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", "unchangedLabel": "(Inchangé)", "unknownWidget": "Widget inconnu : {id}", "unnamed": "(sans nom)", diff --git a/lib/l10n/app_fr_ca.arb b/lib/l10n/app_fr_ca.arb index b887e58dd..d108991f2 100644 --- a/lib/l10n/app_fr_ca.arb +++ b/lib/l10n/app_fr_ca.arb @@ -596,7 +596,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 +762,7 @@ "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", "noClients": "Aucun client", "noDeviceActivityRecorded": "Aucune activité d'appareil enregistrée", "noDevicesCurrentlyConnected": "Aucun appareil n'est actuellement connecté.", @@ -1014,9 +1014,6 @@ "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", "unchangedLabel": "(Inchangé)", "unknownWidget": "Widget inconnu : {id}", "unnamed": "(sans nom)", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 896e82994..81ec2145e 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -594,7 +594,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 +762,7 @@ "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", "noClients": "Tidak ada klien", "noDeviceActivityRecorded": "Tidak ada aktivitas perangkat yang tercatat", "noDevicesCurrentlyConnected": "Tidak ada perangkat yang saat ini tersambung.", @@ -1014,9 +1014,6 @@ "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", "unchangedLabel": "(Tidak diubah)", "unknownWidget": "Widget tidak dikenal: {id}", "unnamed": "(tanpa nama)", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 49ba9b16f..619f83eb5 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -596,7 +596,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 +762,7 @@ "noAdvancedWifiSettings": "Nessuna impostazione WiFi avanzata disponibile per questo dispositivo.", "noAnswersReturned": "Nessuna risposta restituita.", "noAppsInstalled": "Nessuna app installata su questo router", + "unableToLoadApps": "Impossibile caricare le Apps", "noClients": "Nessun client", "noDeviceActivityRecorded": "Nessuna attività del dispositivo registrata", "noDevicesCurrentlyConnected": "Nessun dispositivo attualmente connesso.", @@ -1014,9 +1014,6 @@ "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", "unchangedLabel": "(Invariato)", "unknownWidget": "Widget sconosciuto: {id}", "unnamed": "(senza nome)", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 05adc042b..c315735e1 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -597,7 +597,6 @@ "errorInvalidCredentials": "ユーザー名またはパスワードが正しくありません。", "errorInvalidInput": "入力された値が無効です。確認して再度お試しください。", "errorInvalidSessionToken": "セッションが無効です。再度サインインしてください。", - "errorLoadingSpeedTest": "速度テストの読み込みエラー", "errorNetwork": "ネットワークエラーです。接続を確認して再度お試しください。", "errorNotAuthenticated": "サインインしていません。サインインして再度お試しください。", "errorResourceNotFound": "要求された設定がルーター上に見つかりませんでした。", @@ -763,6 +762,7 @@ "noAdvancedWifiSettings": "このデバイスで利用できる詳細な WiFi 設定はありません。", "noAnswersReturned": "応答が返されませんでした。", "noAppsInstalled": "このルーターにインストールされているアプリはありません", + "unableToLoadApps": "アプリを読み込めません", "noClients": "クライアントなし", "noDeviceActivityRecorded": "記録されたデバイスアクティビティはありません", "noDevicesCurrentlyConnected": "現在接続中のデバイスはありません。", @@ -1014,9 +1014,6 @@ "type": "タイプ", "typeAMessage": "メッセージを入力...", "unableToGatherDeviceInfo": "デバイス情報を取得できません", - "unableToLoadApps": "アプリを読み込めません", - "unableToLoadDiagnostics": "診断を読み込めません", - "unableToLoadSpeedTest": "速度テストを読み込めません", "unchangedLabel": "(変更なし)", "unknownWidget": "不明なウィジェット: {id}", "unnamed": "(名前なし)", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index fd92e3aa3..e1c2e6c82 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -594,7 +594,6 @@ "errorInvalidCredentials": "사용자 이름 또는 암호가 잘못되었습니다.", "errorInvalidInput": "입력한 값이 유효하지 않습니다. 확인 후 다시 시도하세요.", "errorInvalidSessionToken": "세션이 유효하지 않습니다. 다시 로그인하세요.", - "errorLoadingSpeedTest": "속도 테스트 로드 중 오류", "errorNetwork": "네트워크 오류입니다. 연결을 확인하고 다시 시도하세요.", "errorNotAuthenticated": "로그인되어 있지 않습니다. 로그인 후 다시 시도하세요.", "errorResourceNotFound": "요청한 설정을 라우터에서 찾을 수 없습니다.", @@ -763,6 +762,7 @@ "noAdvancedWifiSettings": "이 장치에 사용할 수 있는 고급 WiFi 설정이 없습니다.", "noAnswersReturned": "반환된 응답이 없습니다.", "noAppsInstalled": "이 라우터에 설치된 앱이 없습니다", + "unableToLoadApps": "앱을 로드할 수 없습니다", "noClients": "클라이언트 없음", "noDeviceActivityRecorded": "기록된 장치 활동이 없습니다", "noDevicesCurrentlyConnected": "현재 연결된 장치가 없습니다.", @@ -1014,9 +1014,6 @@ "type": "유형", "typeAMessage": "메시지를 입력하세요...", "unableToGatherDeviceInfo": "장치 정보를 수집할 수 없습니다", - "unableToLoadApps": "앱을 로드할 수 없습니다", - "unableToLoadDiagnostics": "진단을 로드할 수 없습니다", - "unableToLoadSpeedTest": "속도 테스트를 로드할 수 없습니다", "unchangedLabel": "(변경되지 않음)", "unknownWidget": "알 수 없는 위젯: {id}", "unnamed": "(이름 없음)", diff --git a/lib/l10n/app_nb.arb b/lib/l10n/app_nb.arb index da4a42fc5..721a20879 100644 --- a/lib/l10n/app_nb.arb +++ b/lib/l10n/app_nb.arb @@ -596,7 +596,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 +762,7 @@ "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", "noClients": "Ingen klienter", "noDeviceActivityRecorded": "Ingen enhetsaktivitet registrert", "noDevicesCurrentlyConnected": "Ingen enheter er tilkoblet for øyeblikket.", @@ -1014,9 +1014,6 @@ "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", "unchangedLabel": "(Uendret)", "unknownWidget": "Ukjent widget: {id}", "unnamed": "(uten navn)", diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 9f931753c..aca907bd8 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -596,7 +596,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 +762,7 @@ "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", "noClients": "Geen clients", "noDeviceActivityRecorded": "Geen apparaatactiviteit geregistreerd", "noDevicesCurrentlyConnected": "Er zijn momenteel geen apparaten verbonden.", @@ -1014,9 +1014,6 @@ "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", "unchangedLabel": "(Ongewijzigd)", "unknownWidget": "Onbekende widget: {id}", "unnamed": "(naamloos)", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index e942fdb79..1d579170c 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -594,7 +594,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 +762,7 @@ "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", "noClients": "Brak klientów", "noDeviceActivityRecorded": "Nie zarejestrowano aktywności urządzeń", "noDevicesCurrentlyConnected": "Obecnie nie ma podłączonych urządzeń.", @@ -1014,9 +1014,6 @@ "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", "unchangedLabel": "(Bez zmian)", "unknownWidget": "Nieznany widżet: {id}", "unnamed": "(bez nazwy)", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 8064a8fbc..b61de9fc1 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -596,7 +596,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 +762,7 @@ "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", "noClients": "Nenhum cliente", "noDeviceActivityRecorded": "Nenhuma atividade de dispositivo registrada", "noDevicesCurrentlyConnected": "Nenhum dispositivo conectado no momento.", @@ -1014,9 +1014,6 @@ "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", "unchangedLabel": "(Inalterado)", "unknownWidget": "Widget desconhecido: {id}", "unnamed": "(sem nome)", diff --git a/lib/l10n/app_pt_pt.arb b/lib/l10n/app_pt_pt.arb index 84c7aab3a..f4cbf2d78 100644 --- a/lib/l10n/app_pt_pt.arb +++ b/lib/l10n/app_pt_pt.arb @@ -596,7 +596,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 +762,7 @@ "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", "noClients": "Sem clientes", "noDeviceActivityRecorded": "Não foi registada atividade do dispositivo", "noDevicesCurrentlyConnected": "Não há dispositivos ligados de momento.", @@ -1014,9 +1014,6 @@ "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", "unchangedLabel": "(Inalterado)", "unknownWidget": "Widget desconhecido: {id}", "unnamed": "(sem nome)", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 202d66a44..22abf62d9 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -594,7 +594,6 @@ "errorInvalidCredentials": "Неверное имя пользователя или пароль.", "errorInvalidInput": "Введенное значение недействительно. Проверьте и попробуйте снова.", "errorInvalidSessionToken": "Ваш сеанс недействителен. Войдите снова.", - "errorLoadingSpeedTest": "Ошибка загрузки теста скорости", "errorNetwork": "Ошибка сети. Проверьте подключение и попробуйте снова.", "errorNotAuthenticated": "Вы не вошли в систему. Войдите и попробуйте снова.", "errorResourceNotFound": "Запрошенную настройку не удалось найти на маршрутизаторе.", @@ -763,6 +762,7 @@ "noAdvancedWifiSettings": "Расширенные настройки WiFi для этого устройства недоступны.", "noAnswersReturned": "Ответы не получены.", "noAppsInstalled": "На этом маршрутизаторе не установлены приложения", + "unableToLoadApps": "Не удалось загрузить приложения", "noClients": "Нет клиентов", "noDeviceActivityRecorded": "Активность устройств не зафиксирована", "noDevicesCurrentlyConnected": "В настоящее время устройства не подключены.", @@ -1014,9 +1014,6 @@ "type": "Тип", "typeAMessage": "Введите сообщение...", "unableToGatherDeviceInfo": "Не удалось собрать информацию об устройстве", - "unableToLoadApps": "Не удалось загрузить приложения", - "unableToLoadDiagnostics": "Не удалось загрузить диагностику", - "unableToLoadSpeedTest": "Не удалось загрузить тест скорости", "unchangedLabel": "(Без изменений)", "unknownWidget": "Неизвестный виджет: {id}", "unnamed": "(без имени)", diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index 291624ba6..b27732bb0 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -596,7 +596,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 +762,7 @@ "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", "noClients": "Inga klienter", "noDeviceActivityRecorded": "Ingen enhetsaktivitet registrerad", "noDevicesCurrentlyConnected": "Inga enheter är för närvarande anslutna.", @@ -1014,9 +1014,6 @@ "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", "unchangedLabel": "(Oförändrad)", "unknownWidget": "Okänd widget: {id}", "unnamed": "(namnlös)", diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 61fd7bc89..a72263d7a 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -594,7 +594,6 @@ "errorInvalidCredentials": "ชื่อผู้ใช้หรือรหัสผ่านไม่ถูกต้อง", "errorInvalidInput": "ค่าที่ป้อนไม่ถูกต้อง กรุณาตรวจสอบและลองอีกครั้ง", "errorInvalidSessionToken": "เซสชันของคุณไม่ถูกต้อง กรุณาลงชื่อเข้าใช้อีกครั้ง", - "errorLoadingSpeedTest": "เกิดข้อผิดพลาดในการโหลดการทดสอบความเร็ว", "errorNetwork": "เกิดข้อผิดพลาดของเครือข่าย กรุณาตรวจสอบการเชื่อมต่อและลองอีกครั้ง", "errorNotAuthenticated": "คุณยังไม่ได้ลงชื่อเข้าใช้ กรุณาลงชื่อเข้าใช้และลองอีกครั้ง", "errorResourceNotFound": "ไม่พบการตั้งค่าที่ร้องขอบนเราเตอร์", @@ -763,6 +762,7 @@ "noAdvancedWifiSettings": "ไม่มีการตั้งค่า WiFi ขั้นสูงสำหรับอุปกรณ์นี้", "noAnswersReturned": "ไม่มีคำตอบกลับมา", "noAppsInstalled": "ไม่มีแอปติดตั้งบนเราเตอร์นี้", + "unableToLoadApps": "ไม่สามารถโหลดแอปได้", "noClients": "ไม่มีไคลเอนต์", "noDeviceActivityRecorded": "ไม่มีการบันทึกกิจกรรมของอุปกรณ์", "noDevicesCurrentlyConnected": "ไม่มีอุปกรณ์เชื่อมต่ออยู่ในขณะนี้", @@ -1014,9 +1014,6 @@ "type": "ประเภท", "typeAMessage": "พิมพ์ข้อความ...", "unableToGatherDeviceInfo": "ไม่สามารถรวบรวมข้อมูลอุปกรณ์ได้", - "unableToLoadApps": "ไม่สามารถโหลดแอปได้", - "unableToLoadDiagnostics": "ไม่สามารถโหลดการวินิจฉัยได้", - "unableToLoadSpeedTest": "ไม่สามารถโหลดการทดสอบความเร็วได้", "unchangedLabel": "(ไม่เปลี่ยนแปลง)", "unknownWidget": "วิดเจ็ตที่ไม่รู้จัก: {id}", "unnamed": "(ไม่มีชื่อ)", diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index 2e467a764..5d553965a 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -594,7 +594,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 +762,7 @@ "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", "noClients": "İstemci yok", "noDeviceActivityRecorded": "Kaydedilmiş cihaz etkinliği yok", "noDevicesCurrentlyConnected": "Şu anda bağlı cihaz yok.", @@ -1014,9 +1014,6 @@ "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", "unchangedLabel": "(Değişmedi)", "unknownWidget": "Bilinmeyen widget: {id}", "unnamed": "(adsız)", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 1992d1b6f..92b867ca6 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -594,7 +594,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 +762,7 @@ "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", "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.", @@ -1014,9 +1014,6 @@ "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 độ", "unchangedLabel": "(Không thay đổi)", "unknownWidget": "Tiện ích không xác định: {id}", "unnamed": "(chưa đặt tên)", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 2bbc03ec2..c0ae616a6 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -597,7 +597,6 @@ "errorInvalidCredentials": "用户名或密码不正确。", "errorInvalidInput": "输入的值无效。请检查后重试。", "errorInvalidSessionToken": "您的会话无效。请重新登录。", - "errorLoadingSpeedTest": "加载速度测试出错", "errorNetwork": "网络错误。请检查您的连接后重试。", "errorNotAuthenticated": "您尚未登录。请登录后重试。", "errorResourceNotFound": "在路由器上找不到请求的设置。", @@ -763,6 +762,7 @@ "noAdvancedWifiSettings": "此设备没有可用的高级 WiFi 设置。", "noAnswersReturned": "未返回任何应答。", "noAppsInstalled": "此路由器上未安装任何应用", + "unableToLoadApps": "无法加载应用", "noClients": "无客户端", "noDeviceActivityRecorded": "未记录任何设备活动", "noDevicesCurrentlyConnected": "当前没有设备连接。", @@ -1014,9 +1014,6 @@ "type": "类型", "typeAMessage": "输入消息…", "unableToGatherDeviceInfo": "无法收集设备信息", - "unableToLoadApps": "无法加载应用", - "unableToLoadDiagnostics": "无法加载诊断", - "unableToLoadSpeedTest": "无法加载速度测试", "unchangedLabel": "(未更改)", "unknownWidget": "未知小组件:{id}", "unnamed": "(未命名)", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index b73b19c0d..c046ebdb4 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -597,7 +597,6 @@ "errorInvalidCredentials": "使用者名稱或密碼不正確。", "errorInvalidInput": "輸入的值無效。請檢查後再試一次。", "errorInvalidSessionToken": "您的工作階段無效。請重新登入。", - "errorLoadingSpeedTest": "載入速度測試時發生錯誤", "errorNetwork": "網路錯誤。請檢查您的連線後再試一次。", "errorNotAuthenticated": "您尚未登入。請登入後再試一次。", "errorResourceNotFound": "在路由器上找不到要求的設定。", @@ -763,6 +762,7 @@ "noAdvancedWifiSettings": "此裝置沒有可用的進階 WiFi 設定。", "noAnswersReturned": "沒有傳回任何答案。", "noAppsInstalled": "此路由器上未安裝任何 App", + "unableToLoadApps": "無法載入 Apps", "noClients": "沒有用戶端", "noDeviceActivityRecorded": "未記錄任何裝置活動", "noDevicesCurrentlyConnected": "目前沒有裝置連線。", @@ -1014,9 +1014,6 @@ "type": "類型", "typeAMessage": "輸入訊息...", "unableToGatherDeviceInfo": "無法收集裝置資訊", - "unableToLoadApps": "無法載入 App", - "unableToLoadDiagnostics": "無法載入診斷", - "unableToLoadSpeedTest": "無法載入速度測試", "unchangedLabel": "(未變更)", "unknownWidget": "未知的小工具:{id}", "unnamed": "(未命名)", 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/unified_diagnostics/cards/usp_speed_test_card.dart b/lib/page/unified_diagnostics/cards/usp_speed_test_card.dart index 2e2f7a867..7bac30b85 100644 --- a/lib/page/unified_diagnostics/cards/usp_speed_test_card.dart +++ b/lib/page/unified_diagnostics/cards/usp_speed_test_card.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/localization/localization_hook.dart'; import 'package:privacy_gui/page/_shared/components/dashboard_card_template.dart'; import 'package:privacy_gui/page/unified_diagnostics/models/speed_test_state.dart'; @@ -27,31 +29,15 @@ class UspSpeedTestCard extends ConsumerWidget { detailRoute: RouteNamed.uspSpeedTest, content: asyncState.when( loading: () => const Center(child: AppLoader()), - error: (_, __) => _buildError(context, ref), + error: (error, _) => ServiceErrorView( + error: error is ServiceError ? error : null, + onRetry: () => ref.invalidate(speedTestProvider), + ), data: (state) => _buildBody(context, ref, state, colorScheme), ), ); } - Widget _buildError(BuildContext context, WidgetRef ref) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - AppIcon.font(Icons.error_outline, - size: 32, color: Theme.of(context).colorScheme.error), - AppGap.sm(), - AppText.bodySmall(loc(context).errorLoadingSpeedTest), - AppGap.md(), - AppButton.text( - label: loc(context).retry, - onTap: () => ref.invalidate(speedTestProvider), - ), - ], - ), - ); - } - Widget _buildBody( BuildContext context, WidgetRef ref, diff --git a/lib/page/unified_diagnostics/views/speed_test_view.dart b/lib/page/unified_diagnostics/views/speed_test_view.dart index c70c59da7..8a3505604 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,16 @@ 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, + 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/widgets/diagnostic_manual_tools_view.dart b/lib/page/unified_diagnostics/views/widgets/diagnostic_manual_tools_view.dart index 6ac7a78cb..0e0f4540e 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,11 @@ 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, + onRetry: () => ref.invalidate(manualToolsProvider), ), + data: (state) => _buildContent(context, state), ); } From c5e61b34e827a9b374c96697397aa9fd45f3a555 Mon Sep 17 00:00:00 2001 From: Hank Yu Date: Tue, 30 Jun 2026 15:55:10 +0800 Subject: [PATCH 06/56] docs(error-handling): unify AsyncValue pages on ServiceErrorView in guide + constitution --- constitution.md | 7 +-- .../README.md | 0 .../error-handling-implementation-guide.md | 56 ++++++++++++------- .../usp-error-handling-reference.md | 0 4 files changed, 40 insertions(+), 23 deletions(-) rename doc/{error-handling-localization => error-handling}/README.md (100%) rename doc/{error-handling-localization => error-handling}/error-handling-implementation-guide.md (91%) rename doc/{error-handling-localization => error-handling}/usp-error-handling-reference.md (100%) diff --git a/constitution.md b/constitution.md index 8e214a9b2..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-06-29 +**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. @@ -1258,8 +1258,7 @@ string, and it does so through one central mapper — never by stringifying the - **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` (state-based pages) or - call `localizeServiceError` inside `AsyncValue.when(error:)` (AsyncNotifier pages). +- **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. @@ -1269,7 +1268,7 @@ string, and it does so through one central mapper — never by stringifying the > **Full implementation guidance** — per-layer patterns, what to show vs. hide, > batch-failure handling, and a pre-PR checklist — lives in -> `doc/error-handling-localization/error-handling-implementation-guide.md`. +> `doc/error-handling/error-handling-implementation-guide.md`. > This Constitution states the principle; that guide is the how-to. --- diff --git a/doc/error-handling-localization/README.md b/doc/error-handling/README.md similarity index 100% rename from doc/error-handling-localization/README.md rename to doc/error-handling/README.md diff --git a/doc/error-handling-localization/error-handling-implementation-guide.md b/doc/error-handling/error-handling-implementation-guide.md similarity index 91% rename from doc/error-handling-localization/error-handling-implementation-guide.md rename to doc/error-handling/error-handling-implementation-guide.md index e1bcb584f..278d99cfa 100644 --- a/doc/error-handling-localization/error-handling-implementation-guide.md +++ b/doc/error-handling/error-handling-implementation-guide.md @@ -180,7 +180,7 @@ class UspAdminNotifier extends AutoDisposeAsyncNotifier { > **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 ultimately go through the same `localizeServiceError` in the View; only "where the error is stored" and "how the View displays it" differ (see §3). +> Both render fetch failures with the same `ServiceErrorView` in the View (see §3.1); only "where the error is stored" and "how retry is triggered" differ. --- @@ -188,9 +188,17 @@ class UspAdminNotifier extends AutoDisposeAsyncNotifier { 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 (two ways, corresponding to the two providers in §2) +### 3.1 Displaying fetch failures → always `ServiceErrorView` -**(A) state.error pages → use the shared widget [`ServiceErrorView`](../../lib/components/views/service_error_view.dart)**: +Both page architectures render fetch failures 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` already calls `localizeServiceError` internally; you don't translate it +yourself. It shows: an error icon + the `loc(ctx).failedToLoadSettings` title + the +localized detail + a retry button (and an optional secondary action — see below). + +**(A) state.error pages** — pass `status.error` directly (it is already a `ServiceError?`): ```dart if (status.error != null) { @@ -201,29 +209,38 @@ if (status.error != null) { } ``` -`ServiceErrorView` already calls `localizeServiceError` internally; you don't need to translate it yourself. It displays: an error icon + the `loc(ctx).failedToLoadSettings` title + the localized detail + a retry button. - -**(B) AsyncValue pages → call `localizeServiceError` inside `.when(error:)`**: +**(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) => _buildError(context, ref, error), // error is Object + error: (error, stack) => ServiceErrorView( + error: error is ServiceError ? error : null, + onRetry: () => ref.invalidate(uspAdminProvider), + ), data: (state) => _buildContent(context, ref, state), ); - -Widget _buildError(BuildContext context, WidgetRef ref, Object error) { - return Center(child: Column(children: [ - AppIcon.font(Icons.error_outline, size: 48, color: ...error), - AppText.titleMedium(loc(context).failedToLoadSettings), - AppText.bodyMedium(localizeServiceError(context, error)), // ← localize - AppButton(label: loc(context).retry, onTap: () => ref.invalidate(uspAdminProvider)), - ])); -} ``` -> **Why two ways?** `ServiceErrorView` takes a `ServiceError?` and is driven by `state.error`; `AsyncValue.when(error:)` gives an `Object error` (and retry uses `ref.invalidate` rather than `fetch(forceRemote)`). So AsyncValue pages keep their own small `_buildError`, but **the content must follow the snippet above and always go through `localizeServiceError`** — do not hardcode English yourself. -> `localizeServiceError`'s second parameter takes `Object` (defensively): a non-ServiceError falls back to `errorUnexpected`, so it is safe for AsyncValue to pass the `Object error` straight in. +> **Why the narrow?** `ServiceErrorView.error` is `ServiceError?`, but `AsyncValue.when(error:)` +> gives `Object`. `ServiceErrorView` accepts `null` (it then shows just the generic title), so +> a non-ServiceError degrades safely. (One known case: the `apps` page fetches lighttpd static +> JSON and throws plain `Exception`, not `ServiceError` — it deliberately keeps its own error +> widget instead of `ServiceErrorView`. See §7.) + +**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`: + +```dart +ServiceErrorView( + error: error is ServiceError ? error : null, + onRetry: () => ref.read(dashboardOrchestratorProvider.notifier).refreshAll(), + secondaryLabel: loc(context).logout, + onSecondary: () => _logout(context, ref), +); +``` ### 3.2 Displaying save failures → snackbar @@ -397,6 +414,7 @@ Only do this when none of the existing types can express a certain error semanti - **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). --- @@ -407,7 +425,7 @@ Only do this when none of the existing types can express a certain error semanti - [ ] 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 fetch failure goes through `ServiceErrorView` (state.error) or `_buildError + localizeServiceError` (AsyncValue). +- [ ] View's fetch failure renders `ServiceErrorView` (state.error pages pass `status.error`; AsyncValue pages pass `error is ServiceError ? error : null` inside `.when(error:)`). - [ ] 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'`). diff --git a/doc/error-handling-localization/usp-error-handling-reference.md b/doc/error-handling/usp-error-handling-reference.md similarity index 100% rename from doc/error-handling-localization/usp-error-handling-reference.md rename to doc/error-handling/usp-error-handling-reference.md From ed4fd9e1a16d81ae035513bd87728e1e58ceec43 Mon Sep 17 00:00:00 2001 From: Hank Yu Date: Wed, 1 Jul 2026 17:10:27 +0800 Subject: [PATCH 07/56] =?UTF-8?q?fix(l10n):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20error=20titles,=20compact=20card,=20missed=20call-s?= =?UTF-8?q?ites?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-1 review fixes for the ServiceErrorView migration: - ServiceErrorView: add optional `title` (defaults to neutral errorUnexpected) so non-settings pages no longer show "Failed to load settings"; add assert for paired secondary action; unwrap onSecondary via local promotion. (C-1/W-1/W-3) - Restore the 4 error-title ARB keys (topology/speedTest/diagnostics) to their original positions across 26 locales; every ServiceErrorView caller now passes a context-appropriate title. (C-1) - speed_test_card: revert to a compact card-sized error widget instead of the full-page ServiceErrorView (avoids DashboardCardTemplate overflow); localizes via localizeServiceError; drop now-orphaned errorLoadingSpeedTest. (C-2) - device_list: raw '$e' → ServiceErrorView (unableToGatherDeviceInfo). (C-3) - health_status_view: hardcoded English → new localized unableToLoadHealthData (26 locales), kept as the existing inline widget (not full-page). (C-4) - l10n polish: fix unableToLoadApps capitalization to each locale's in-sentence convention (de stays capitalized); align pt/ja/th/fr terminology. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/components/views/service_error_view.dart | 18 +++++++--- lib/l10n/app_ar.arb | 4 +++ lib/l10n/app_da.arb | 6 +++- lib/l10n/app_de.arb | 4 +++ lib/l10n/app_el.arb | 4 +++ lib/l10n/app_en.arb | 6 +++- lib/l10n/app_es.arb | 4 +++ lib/l10n/app_es_ar.arb | 6 +++- lib/l10n/app_fi.arb | 4 +++ lib/l10n/app_fr.arb | 4 +++ lib/l10n/app_fr_ca.arb | 4 +++ lib/l10n/app_id.arb | 4 +++ lib/l10n/app_it.arb | 6 +++- lib/l10n/app_ja.arb | 4 +++ lib/l10n/app_ko.arb | 4 +++ lib/l10n/app_nb.arb | 4 +++ lib/l10n/app_nl.arb | 6 +++- lib/l10n/app_pl.arb | 4 +++ lib/l10n/app_pt.arb | 6 +++- lib/l10n/app_pt_pt.arb | 4 +++ lib/l10n/app_ru.arb | 4 +++ lib/l10n/app_sv.arb | 4 +++ lib/l10n/app_th.arb | 4 +++ lib/l10n/app_tr.arb | 4 +++ lib/l10n/app_vi.arb | 4 +++ lib/l10n/app_zh.arb | 4 +++ lib/l10n/app_zh_TW.arb | 6 +++- lib/page/admin/views/usp_admin_view.dart | 1 + .../mascot/widgets/health_status_view.dart | 2 +- .../dashboard/views/usp_dashboard_view.dart | 1 + .../devices/views/usp_device_list_view.dart | 9 +++-- lib/page/dhcp/views/usp_dhcp_detail_view.dart | 2 ++ lib/page/dmz/views/usp_dmz_view.dart | 1 + .../firewall/views/usp_firewall_view.dart | 1 + .../views/instant_privacy_view.dart | 1 + .../views/instant_safety_view.dart | 1 + .../views/usp_internet_settings_view.dart | 1 + .../views/usp_ipv6_port_service_view.dart | 1 + .../views/usp_local_network_view.dart | 1 + .../usp_port_forwarding_detail_view.dart | 1 + .../views/usp_static_routing_view.dart | 1 + .../system_log/views/usp_system_log_view.dart | 1 + .../topology/views/usp_topology_view.dart | 1 + .../cards/usp_speed_test_card.dart | 36 +++++++++++++++---- .../views/speed_test_view.dart | 1 + .../widgets/diagnostic_manual_tools_view.dart | 1 + .../views/tabs/wifi_advanced_tab.dart | 1 + .../views/service_error_view_test.dart | 25 ++++++++++--- 48 files changed, 202 insertions(+), 24 deletions(-) diff --git a/lib/components/views/service_error_view.dart b/lib/components/views/service_error_view.dart index 1725ad254..65f2f165e 100644 --- a/lib/components/views/service_error_view.dart +++ b/lib/components/views/service_error_view.dart @@ -16,9 +16,15 @@ 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 to show it. + /// Both [secondaryLabel] and [onSecondary] must be provided (or neither). final String? secondaryLabel; final VoidCallback? onSecondary; @@ -26,14 +32,18 @@ class ServiceErrorView extends StatelessWidget { 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, @@ -41,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), @@ -54,7 +64,7 @@ class ServiceErrorView extends StatelessWidget { if (secondaryLabel != null && onSecondary != null) ...[ AppGap.md(), AppButton.text( - label: secondaryLabel!, + label: secondaryLabel, onTap: onSecondary, ), ], diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 0b8257588..90cad26b3 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -763,6 +763,8 @@ "noAnswersReturned": "لم تُرجَع أي إجابات.", "noAppsInstalled": "لا توجد تطبيقات مثبتة على جهاز التوجيه هذا", "unableToLoadApps": "تعذّر تحميل التطبيقات", + "unableToLoadHealthData": "تعذّر تحميل بيانات الحالة", + "unableToLoadTopology": "تعذّر تحميل الطوبولوجيا", "noClients": "لا يوجد عملاء", "noDeviceActivityRecorded": "لم يُسجّل أي نشاط للجهاز", "noDevicesCurrentlyConnected": "لا توجد أجهزة متصلة حاليًا.", @@ -914,6 +916,8 @@ "routerRebootComplete": "اكتملت إعادة تشغيل جهاز التوجيه", "routerWritingImage": "يقوم جهاز التوجيه بكتابة الصورة الجديدة. لا تقم بإيقاف التشغيل.", "rtColumn": "RT", + "unableToLoadDiagnostics": "تعذّر تحميل التشخيص", + "unableToLoadSpeedTest": "تعذّر تحميل اختبار السرعة", "rules": "القواعد", "runAgain": "تشغيل مرة أخرى", "runDiagnosticsTitle": "تشغيل التشخيص", diff --git a/lib/l10n/app_da.arb b/lib/l10n/app_da.arb index e32de1ee1..84e3c40f9 100644 --- a/lib/l10n/app_da.arb +++ b/lib/l10n/app_da.arb @@ -762,7 +762,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", + "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.", @@ -914,6 +916,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", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 97164ce7c..2cc43775a 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -763,6 +763,8 @@ "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.", @@ -914,6 +916,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", diff --git a/lib/l10n/app_el.arb b/lib/l10n/app_el.arb index 33563695b..1fb810bca 100644 --- a/lib/l10n/app_el.arb +++ b/lib/l10n/app_el.arb @@ -763,6 +763,8 @@ "noAnswersReturned": "Δεν επιστράφηκαν απαντήσεις.", "noAppsInstalled": "Δεν υπάρχουν εγκατεστημένες εφαρμογές σε αυτόν τον δρομολογητή", "unableToLoadApps": "Δεν είναι δυνατή η φόρτωση των εφαρμογών", + "unableToLoadHealthData": "Δεν είναι δυνατή η φόρτωση των δεδομένων κατάστασης", + "unableToLoadTopology": "Δεν είναι δυνατή η φόρτωση της τοπολογίας", "noClients": "Δεν υπάρχουν πελάτες", "noDeviceActivityRecorded": "Δεν καταγράφηκε δραστηριότητα συσκευής", "noDevicesCurrentlyConnected": "Δεν υπάρχουν συσκευές συνδεδεμένες αυτή τη στιγμή.", @@ -914,6 +916,8 @@ "routerRebootComplete": "Η επανεκκίνηση του δρομολογητή ολοκληρώθηκε", "routerWritingImage": "Ο δρομολογητής εγγράφει τη νέα εικόνα. Μην απενεργοποιείτε τη συσκευή.", "rtColumn": "RT", + "unableToLoadDiagnostics": "Δεν είναι δυνατή η φόρτωση των διαγνωστικών", + "unableToLoadSpeedTest": "Δεν είναι δυνατή η φόρτωση του τεστ ταχύτητας", "rules": "Κανόνες", "runAgain": "Εκτέλεση ξανά", "runDiagnosticsTitle": "Εκτέλεση διαγνωστικών", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index dd1f9662e..6f058417e 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1040,6 +1040,8 @@ "noAnswersReturned": "No answers returned.", "ipsColumn": "IPs", "rtColumn": "RT", + "unableToLoadDiagnostics": "Unable to load diagnostics", + "unableToLoadSpeedTest": "Unable to load speed test", "internetSpeedTest": "Internet Speed Test", "testConnectionSpeedFromRouter": "Test your connection speed from the router", "startTest": "Start Test", @@ -1605,7 +1607,9 @@ "networkTopology": "Network Topology", "newPassword": "New Password", "noAppsInstalled": "No apps installed on this router", - "unableToLoadApps": "Unable to load Apps", + "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", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index e9dacfa13..460b00ac8 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -763,6 +763,8 @@ "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.", @@ -914,6 +916,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", diff --git a/lib/l10n/app_es_ar.arb b/lib/l10n/app_es_ar.arb index a0d85ab71..35bc4c156 100644 --- a/lib/l10n/app_es_ar.arb +++ b/lib/l10n/app_es_ar.arb @@ -762,7 +762,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", + "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.", @@ -914,6 +916,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", diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb index 153bace22..78ef6f32b 100644 --- a/lib/l10n/app_fi.arb +++ b/lib/l10n/app_fi.arb @@ -763,6 +763,8 @@ "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ä.", @@ -914,6 +916,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", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 598c3a03a..0918b6649 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -763,6 +763,8 @@ "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é.", @@ -914,6 +916,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", diff --git a/lib/l10n/app_fr_ca.arb b/lib/l10n/app_fr_ca.arb index d108991f2..33c6b7c98 100644 --- a/lib/l10n/app_fr_ca.arb +++ b/lib/l10n/app_fr_ca.arb @@ -763,6 +763,8 @@ "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é.", @@ -914,6 +916,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", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 81ec2145e..6f761d9fd 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -763,6 +763,8 @@ "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.", @@ -914,6 +916,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", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 619f83eb5..0386e7851 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -762,7 +762,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 Apps", + "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.", @@ -914,6 +916,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", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index c315735e1..354c43292 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -763,6 +763,8 @@ "noAnswersReturned": "応答が返されませんでした。", "noAppsInstalled": "このルーターにインストールされているアプリはありません", "unableToLoadApps": "アプリを読み込めません", + "unableToLoadHealthData": "ヘルスデータを読み込めません", + "unableToLoadTopology": "トポロジーを読み込めません", "noClients": "クライアントなし", "noDeviceActivityRecorded": "記録されたデバイスアクティビティはありません", "noDevicesCurrentlyConnected": "現在接続中のデバイスはありません。", @@ -914,6 +916,8 @@ "routerRebootComplete": "ルーターの再起動が完了しました", "routerWritingImage": "ルーターが新しいイメージを書き込んでいます。電源を切らないでください。", "rtColumn": "RT", + "unableToLoadDiagnostics": "診断を読み込めません", + "unableToLoadSpeedTest": "スピードテストを読み込めません", "rules": "ルール", "runAgain": "再実行", "runDiagnosticsTitle": "診断を実行", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index e1c2e6c82..ff7fab347 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -763,6 +763,8 @@ "noAnswersReturned": "반환된 응답이 없습니다.", "noAppsInstalled": "이 라우터에 설치된 앱이 없습니다", "unableToLoadApps": "앱을 로드할 수 없습니다", + "unableToLoadHealthData": "상태 데이터를 로드할 수 없습니다", + "unableToLoadTopology": "토폴로지를 로드할 수 없습니다", "noClients": "클라이언트 없음", "noDeviceActivityRecorded": "기록된 장치 활동이 없습니다", "noDevicesCurrentlyConnected": "현재 연결된 장치가 없습니다.", @@ -914,6 +916,8 @@ "routerRebootComplete": "라우터 재부팅 완료", "routerWritingImage": "라우터가 새 이미지를 기록 중입니다. 전원을 끄지 마세요.", "rtColumn": "RT", + "unableToLoadDiagnostics": "진단을 로드할 수 없습니다", + "unableToLoadSpeedTest": "속도 테스트를 로드할 수 없습니다", "rules": "규칙", "runAgain": "다시 실행", "runDiagnosticsTitle": "진단 실행", diff --git a/lib/l10n/app_nb.arb b/lib/l10n/app_nb.arb index 721a20879..65a930c0c 100644 --- a/lib/l10n/app_nb.arb +++ b/lib/l10n/app_nb.arb @@ -763,6 +763,8 @@ "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.", @@ -914,6 +916,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", diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index aca907bd8..062b9850f 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -762,7 +762,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", + "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.", @@ -914,6 +916,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", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 1d579170c..1caf45f63 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -763,6 +763,8 @@ "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ń.", @@ -914,6 +916,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ę", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index b61de9fc1..6d4d0eb7d 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -762,7 +762,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", + "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.", @@ -914,6 +916,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", diff --git a/lib/l10n/app_pt_pt.arb b/lib/l10n/app_pt_pt.arb index f4cbf2d78..d39f3a9aa 100644 --- a/lib/l10n/app_pt_pt.arb +++ b/lib/l10n/app_pt_pt.arb @@ -763,6 +763,8 @@ "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.", @@ -914,6 +916,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", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 22abf62d9..3fe929b65 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -763,6 +763,8 @@ "noAnswersReturned": "Ответы не получены.", "noAppsInstalled": "На этом маршрутизаторе не установлены приложения", "unableToLoadApps": "Не удалось загрузить приложения", + "unableToLoadHealthData": "Не удалось загрузить данные о состоянии", + "unableToLoadTopology": "Не удалось загрузить топологию", "noClients": "Нет клиентов", "noDeviceActivityRecorded": "Активность устройств не зафиксирована", "noDevicesCurrentlyConnected": "В настоящее время устройства не подключены.", @@ -914,6 +916,8 @@ "routerRebootComplete": "Перезагрузка маршрутизатора завершена", "routerWritingImage": "Маршрутизатор записывает новый образ. Не выключайте питание.", "rtColumn": "RT", + "unableToLoadDiagnostics": "Не удалось загрузить диагностику", + "unableToLoadSpeedTest": "Не удалось загрузить тест скорости", "rules": "Правила", "runAgain": "Запустить снова", "runDiagnosticsTitle": "Запустить диагностику", diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index b27732bb0..041dd51a8 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -763,6 +763,8 @@ "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.", @@ -914,6 +916,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", diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index a72263d7a..54631a6f6 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -763,6 +763,8 @@ "noAnswersReturned": "ไม่มีคำตอบกลับมา", "noAppsInstalled": "ไม่มีแอปติดตั้งบนเราเตอร์นี้", "unableToLoadApps": "ไม่สามารถโหลดแอปได้", + "unableToLoadHealthData": "ไม่สามารถโหลดข้อมูลความสมบูรณ์ได้", + "unableToLoadTopology": "ไม่สามารถโหลดโทโพโลยีได้", "noClients": "ไม่มีไคลเอนต์", "noDeviceActivityRecorded": "ไม่มีการบันทึกกิจกรรมของอุปกรณ์", "noDevicesCurrentlyConnected": "ไม่มีอุปกรณ์เชื่อมต่ออยู่ในขณะนี้", @@ -914,6 +916,8 @@ "routerRebootComplete": "รีบูตเราเตอร์เสร็จสมบูรณ์", "routerWritingImage": "เราเตอร์กำลังเขียนอิมเมจใหม่ อย่าปิดเครื่อง", "rtColumn": "RT", + "unableToLoadDiagnostics": "ไม่สามารถโหลดการวินิจฉัยได้", + "unableToLoadSpeedTest": "ไม่สามารถโหลดการทดสอบความเร็วได้", "rules": "กฎ", "runAgain": "เรียกใช้อีกครั้ง", "runDiagnosticsTitle": "เรียกใช้การวินิจฉัย", diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index 5d553965a..3d1e94040 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -763,6 +763,8 @@ "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.", @@ -914,6 +916,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", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 92b867ca6..e1da3c2c7 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -763,6 +763,8 @@ "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.", @@ -914,6 +916,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", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index c0ae616a6..24eddb5f6 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -763,6 +763,8 @@ "noAnswersReturned": "未返回任何应答。", "noAppsInstalled": "此路由器上未安装任何应用", "unableToLoadApps": "无法加载应用", + "unableToLoadHealthData": "无法加载健康状况数据", + "unableToLoadTopology": "无法加载拓扑", "noClients": "无客户端", "noDeviceActivityRecorded": "未记录任何设备活动", "noDevicesCurrentlyConnected": "当前没有设备连接。", @@ -914,6 +916,8 @@ "routerRebootComplete": "路由器重启完成", "routerWritingImage": "路由器正在写入新镜像。请勿断电。", "rtColumn": "RT", + "unableToLoadDiagnostics": "无法加载诊断", + "unableToLoadSpeedTest": "无法加载速度测试", "rules": "规则", "runAgain": "再次运行", "runDiagnosticsTitle": "运行诊断", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index c046ebdb4..1fb7eeff2 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -762,7 +762,9 @@ "noAdvancedWifiSettings": "此裝置沒有可用的進階 WiFi 設定。", "noAnswersReturned": "沒有傳回任何答案。", "noAppsInstalled": "此路由器上未安裝任何 App", - "unableToLoadApps": "無法載入 Apps", + "unableToLoadApps": "無法載入 App", + "unableToLoadHealthData": "無法載入健康狀態資料", + "unableToLoadTopology": "無法載入拓撲", "noClients": "沒有用戶端", "noDeviceActivityRecorded": "未記錄任何裝置活動", "noDevicesCurrentlyConnected": "目前沒有裝置連線。", @@ -914,6 +916,8 @@ "routerRebootComplete": "路由器重新啟動完成", "routerWritingImage": "路由器正在寫入新映像檔。請勿關閉電源。", "rtColumn": "RT", + "unableToLoadDiagnostics": "無法載入診斷", + "unableToLoadSpeedTest": "無法載入速度測試", "rules": "規則", "runAgain": "再次執行", "runDiagnosticsTitle": "執行診斷", diff --git a/lib/page/admin/views/usp_admin_view.dart b/lib/page/admin/views/usp_admin_view.dart index e0900b060..92db7bedc 100644 --- a/lib/page/admin/views/usp_admin_view.dart +++ b/lib/page/admin/views/usp_admin_view.dart @@ -49,6 +49,7 @@ class UspAdminView extends ConsumerWidget { ), error: (error, stack) => ServiceErrorView( error: error is ServiceError ? error : null, + title: loc(context).failedToLoadSettings, onRetry: () => ref.invalidate(uspAdminProvider), ), data: (state) => _buildContent(childContext, ref, state), 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/views/usp_dashboard_view.dart b/lib/page/dashboard/views/usp_dashboard_view.dart index 4f5b5ca3b..a6679c4ca 100644 --- a/lib/page/dashboard/views/usp_dashboard_view.dart +++ b/lib/page/dashboard/views/usp_dashboard_view.dart @@ -54,6 +54,7 @@ class UspDashboardView extends ConsumerWidget { ), error: (error, stack) => ServiceErrorView( error: error is ServiceError ? error : null, + title: loc(context).failedToLoadSettings, onRetry: () => ref .read(dashboardOrchestratorProvider.notifier) .refreshAll(), diff --git a/lib/page/devices/views/usp_device_list_view.dart b/lib/page/devices/views/usp_device_list_view.dart index 8c7cf9199..12fe57340 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: (_) => 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/views/usp_dmz_view.dart b/lib/page/dmz/views/usp_dmz_view.dart index a99340091..5b993ac9a 100644 --- a/lib/page/dmz/views/usp_dmz_view.dart +++ b/lib/page/dmz/views/usp_dmz_view.dart @@ -76,6 +76,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), ); 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/instant_privacy/views/instant_privacy_view.dart b/lib/page/instant_privacy/views/instant_privacy_view.dart index 496e26fdd..b882b9742 100644 --- a/lib/page/instant_privacy/views/instant_privacy_view.dart +++ b/lib/page/instant_privacy/views/instant_privacy_view.dart @@ -39,6 +39,7 @@ class InstantPrivacyView extends ConsumerWidget { loading: () => const Center(child: AppLoader()), error: (error, _) => ServiceErrorView( error: error is ServiceError ? error : null, + title: loc(context).failedToLoadSettings, onRetry: () => ref.invalidate(uspInstantPrivacyProvider), ), data: (state) => _buildContent(context, ref, state), 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/internet_settings/views/usp_internet_settings_view.dart b/lib/page/internet_settings/views/usp_internet_settings_view.dart index 4c17ef6e5..da2fc4560 100644 --- a/lib/page/internet_settings/views/usp_internet_settings_view.dart +++ b/lib/page/internet_settings/views/usp_internet_settings_view.dart @@ -47,6 +47,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(), ); 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..f337236fd 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), 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..5bccc3d0d 100644 --- a/lib/page/local_network/views/usp_local_network_view.dart +++ b/lib/page/local_network/views/usp_local_network_view.dart @@ -108,6 +108,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), 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..64b1bea73 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 @@ -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/static_routing/views/usp_static_routing_view.dart b/lib/page/static_routing/views/usp_static_routing_view.dart index eaae9021c..bd4549252 100644 --- a/lib/page/static_routing/views/usp_static_routing_view.dart +++ b/lib/page/static_routing/views/usp_static_routing_view.dart @@ -43,6 +43,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), 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 9e829d04d..39e26e692 100644 --- a/lib/page/system_log/views/usp_system_log_view.dart +++ b/lib/page/system_log/views/usp_system_log_view.dart @@ -32,6 +32,7 @@ class UspSystemLogView extends ConsumerWidget { loading: () => const Center(child: AppLoader()), error: (error, stack) => ServiceErrorView( error: error is ServiceError ? error : null, + title: loc(context).failedToLoadSettings, onRetry: () => ref.invalidate(uspSystemLogProvider), ), data: (logFiles) => _buildContent(context, logFiles), diff --git a/lib/page/topology/views/usp_topology_view.dart b/lib/page/topology/views/usp_topology_view.dart index ef47899ec..9fdc01afe 100644 --- a/lib/page/topology/views/usp_topology_view.dart +++ b/lib/page/topology/views/usp_topology_view.dart @@ -48,6 +48,7 @@ class _UspTopologyViewState extends ConsumerState { loading: () => const Center(child: AppLoader()), error: (error, _) => ServiceErrorView( error: error is ServiceError ? error : null, + title: loc(context).unableToLoadTopology, onRetry: () => ref.invalidate(devicesDataProvider), ), data: (data) { 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 7bac30b85..565dabed9 100644 --- a/lib/page/unified_diagnostics/cards/usp_speed_test_card.dart +++ b/lib/page/unified_diagnostics/cards/usp_speed_test_card.dart @@ -2,8 +2,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/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/localization/localization_hook.dart'; import 'package:privacy_gui/page/_shared/components/dashboard_card_template.dart'; import 'package:privacy_gui/page/unified_diagnostics/models/speed_test_state.dart'; @@ -29,15 +27,41 @@ class UspSpeedTestCard extends ConsumerWidget { detailRoute: RouteNamed.uspSpeedTest, content: asyncState.when( loading: () => const Center(child: AppLoader()), - error: (error, _) => ServiceErrorView( - error: error is ServiceError ? error : null, - onRetry: () => ref.invalidate(speedTestProvider), - ), + error: (error, _) => _buildError(context, ref, error), data: (state) => _buildBody(context, ref, state, colorScheme), ), ); } + /// 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, + children: [ + AppIcon.font(Icons.error_outline, + size: 32, color: Theme.of(context).colorScheme.error), + AppGap.sm(), + 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, + onTap: () => ref.invalidate(speedTestProvider), + ), + ], + ), + ); + } + Widget _buildBody( BuildContext context, WidgetRef ref, diff --git a/lib/page/unified_diagnostics/views/speed_test_view.dart b/lib/page/unified_diagnostics/views/speed_test_view.dart index 8a3505604..b12ad0085 100644 --- a/lib/page/unified_diagnostics/views/speed_test_view.dart +++ b/lib/page/unified_diagnostics/views/speed_test_view.dart @@ -36,6 +36,7 @@ class SpeedTestView extends ConsumerWidget { loading: () => const Center(child: AppLoader()), error: (error, _) => ServiceErrorView( error: error is ServiceError ? error : null, + title: loc(context).unableToLoadSpeedTest, onRetry: () => ref.invalidate(speedTestProvider), ), data: (state) => _buildContent(context, ref, state), 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 0e0f4540e..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 @@ -53,6 +53,7 @@ class _DiagnosticManualToolsViewState loading: () => const Center(child: AppLoader()), 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/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/test/components/views/service_error_view_test.dart b/test/components/views/service_error_view_test.dart index e7dc1f9bc..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', From 48601833f10eb87bca17f44036e71e904ae6ae2f Mon Sep 17 00:00:00 2001 From: Hank Yu Date: Wed, 1 Jul 2026 17:27:11 +0800 Subject: [PATCH 08/56] docs(error-handling): sync implementation guide with review fixes (title param, compact widgets) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../error-handling-implementation-guide.md | 58 ++++++++++++++----- 1 file changed, 43 insertions(+), 15 deletions(-) diff --git a/doc/error-handling/error-handling-implementation-guide.md b/doc/error-handling/error-handling-implementation-guide.md index 278d99cfa..8927665f2 100644 --- a/doc/error-handling/error-handling-implementation-guide.md +++ b/doc/error-handling/error-handling-implementation-guide.md @@ -180,7 +180,7 @@ class UspAdminNotifier extends AutoDisposeAsyncNotifier { > **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 fetch failures with the same `ServiceErrorView` in the View (see §3.1); only "where the error is stored" and "how retry is triggered" differ. +> 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.) --- @@ -188,15 +188,23 @@ class UspAdminNotifier extends AutoDisposeAsyncNotifier { 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 → always `ServiceErrorView` +### 3.1 Displaying fetch failures → full-page `ServiceErrorView` -Both page architectures render fetch failures 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. +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` already calls `localizeServiceError` internally; you don't translate it -yourself. It shows: an error icon + the `loc(ctx).failedToLoadSettings` title + the -localized detail + a retry button (and an optional secondary action — see below). +`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?`): @@ -204,6 +212,7 @@ localized detail + a retry button (and an optional secondary action — see belo if (status.error != null) { return ServiceErrorView( error: status.error, + title: loc(context).failedToLoadSettings, onRetry: () => ref.read(uspDmzProvider.notifier).fetch(forceRemote: true), ); } @@ -218,30 +227,47 @@ asyncState.when( loading: () => const Center(child: AppLoader()), error: (error, stack) => ServiceErrorView( error: error is ServiceError ? error : null, - onRetry: () => ref.invalidate(uspAdminProvider), + 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 generic title), so -> a non-ServiceError degrades safely. (One known case: the `apps` page fetches lighttpd static -> JSON and throws plain `Exception`, not `ServiceError` — it deliberately keeps its own error -> widget instead of `ServiceErrorView`. See §7.) +> 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`: +"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` @@ -401,6 +427,8 @@ Only do this when none of the existing types can express a certain error semanti | 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 | @@ -425,7 +453,7 @@ Only do this when none of the existing types can express a certain error semanti - [ ] 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 fetch failure renders `ServiceErrorView` (state.error pages pass `status.error`; AsyncValue pages pass `error is ServiceError ? error : null` inside `.when(error:)`). +- [ ] 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'`). From dd9ce4009b923f73f4bae2f5826a8da0ee0b7227 Mon Sep 17 00:00:00 2001 From: AustinChangLinksys <79675086+AustinChangLinksys@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:26:07 +0800 Subject: [PATCH 09/56] feat(wifi): add channel dropdown to edit dialog (#1023) (#1027) * feat(wifi): add channel dropdown to edit dialog (#1023) Replace manual channel number entry (AppTextField) with a dropdown (AppDropdown) in the Dashboard WiFi Status edit-channel dialog. - Enrich WifiRadioUIModel with possibleChannels (populated at dashboard fetch by UspWifiDataService, so the dialog renders synchronously with no per-dialog fetch/loading/error state). - Dialog offers Auto (recommended) + the band's possible channels; Auto switch and dropdown stay consistent; DFS channels annotated. - Add 4 ARB keys across all 26 locales. - Unit tests for model, data-service enrichment, and dialog behaviour. * fix(wifi): lock channel dropdown in Auto mode + always show current channel (#1023) * fix(wifi): normalize band for DFS + filter invalid channels from PossibleChannels (#1023) * refactor(wifi): drop redundant IgnorePointer, bump UI-kit v2.26.0->v2.26.1 (#1023) UI-kit v2.26.1 gates the AppDropdown tap gesture when onChanged is null (app_dropdown.dart:138,183), so the consumer-side IgnorePointer workaround added for upstream privacyGUI-UI-kit#2 is now redundant. Remove it and rely on onChanged==null to disable the control. Fix#1 interaction tests converted from widget-tree (IgnorePointer.ignoring) to behavior (onChanged null/menu does not open) assertions; all 16 tests pass. --- lib/l10n/app_ar.arb | 11 + lib/l10n/app_da.arb | 11 + lib/l10n/app_de.arb | 11 + lib/l10n/app_el.arb | 11 + lib/l10n/app_en.arb | 11 + lib/l10n/app_es.arb | 11 + lib/l10n/app_es_ar.arb | 11 + lib/l10n/app_fi.arb | 11 + lib/l10n/app_fr.arb | 11 + lib/l10n/app_fr_ca.arb | 11 + lib/l10n/app_id.arb | 11 + lib/l10n/app_it.arb | 11 + lib/l10n/app_ja.arb | 11 + lib/l10n/app_ko.arb | 11 + lib/l10n/app_nb.arb | 11 + lib/l10n/app_nl.arb | 11 + lib/l10n/app_pl.arb | 11 + lib/l10n/app_pt.arb | 11 + lib/l10n/app_pt_pt.arb | 11 + lib/l10n/app_ru.arb | 11 + lib/l10n/app_sv.arb | 11 + lib/l10n/app_th.arb | 11 + lib/l10n/app_tr.arb | 11 + lib/l10n/app_vi.arb | 11 + lib/l10n/app_zh.arb | 11 + lib/l10n/app_zh_TW.arb | 11 + .../_shared/models/wifi_radio_ui_model.dart | 10 + .../views/dialogs/wifi_channel_dialog.dart | 122 +++++-- .../services/usp_wifi_data_service.dart | 36 +- pubspec.yaml | 4 +- .../models/wifi_radio_ui_model_test.dart | 29 ++ .../dialogs/wifi_channel_dialog_test.dart | 328 ++++++++++++++++++ .../services/usp_wifi_data_service_test.dart | 96 +++++ 33 files changed, 886 insertions(+), 25 deletions(-) create mode 100644 test/page/dashboard/views/dialogs/wifi_channel_dialog_test.dart diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 90cad26b3..c4616e5f6 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -435,6 +435,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": "التحقق من التحديثات", diff --git a/lib/l10n/app_da.arb b/lib/l10n/app_da.arb index 84e3c40f9..c045bf7f2 100644 --- a/lib/l10n/app_da.arb +++ b/lib/l10n/app_da.arb @@ -434,6 +434,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", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 2cc43775a..a44827e0c 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -434,6 +434,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", diff --git a/lib/l10n/app_el.arb b/lib/l10n/app_el.arb index 1fb810bca..68152b4cd 100644 --- a/lib/l10n/app_el.arb +++ b/lib/l10n/app_el.arb @@ -432,6 +432,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": "Έλεγχος για ενημερώσεις", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 6f058417e..bc798ed22 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1186,6 +1186,17 @@ "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", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 460b00ac8..5f38da38a 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -434,6 +434,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", diff --git a/lib/l10n/app_es_ar.arb b/lib/l10n/app_es_ar.arb index 35bc4c156..7fc66cd49 100644 --- a/lib/l10n/app_es_ar.arb +++ b/lib/l10n/app_es_ar.arb @@ -434,6 +434,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", diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb index 78ef6f32b..6a316390a 100644 --- a/lib/l10n/app_fi.arb +++ b/lib/l10n/app_fi.arb @@ -432,6 +432,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", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 0918b6649..bb4b38d89 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -434,6 +434,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", diff --git a/lib/l10n/app_fr_ca.arb b/lib/l10n/app_fr_ca.arb index 33c6b7c98..03f4afea5 100644 --- a/lib/l10n/app_fr_ca.arb +++ b/lib/l10n/app_fr_ca.arb @@ -434,6 +434,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", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 6f761d9fd..1ad97080a 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -432,6 +432,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", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 0386e7851..d95e26794 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -434,6 +434,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", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 354c43292..48952628d 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -435,6 +435,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": "更新を確認", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index ff7fab347..e7b369ac0 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -432,6 +432,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": "업데이트 확인", diff --git a/lib/l10n/app_nb.arb b/lib/l10n/app_nb.arb index 65a930c0c..9e5930ae3 100644 --- a/lib/l10n/app_nb.arb +++ b/lib/l10n/app_nb.arb @@ -434,6 +434,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", diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 062b9850f..4af9d2be0 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -434,6 +434,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", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 1caf45f63..fffafcce6 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -432,6 +432,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", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 6d4d0eb7d..d95771258 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -434,6 +434,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", diff --git a/lib/l10n/app_pt_pt.arb b/lib/l10n/app_pt_pt.arb index d39f3a9aa..937751d02 100644 --- a/lib/l10n/app_pt_pt.arb +++ b/lib/l10n/app_pt_pt.arb @@ -434,6 +434,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", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 3fe929b65..66f263ee2 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -432,6 +432,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": "Проверить обновления", diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index 041dd51a8..c87d3d0ee 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -434,6 +434,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", diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 54631a6f6..baf7684e5 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -432,6 +432,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": "ตรวจหาการอัปเดต", diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index 3d1e94040..7c70318fd 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -432,6 +432,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", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index e1da3c2c7..836b51331 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -432,6 +432,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", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 24eddb5f6..f8a5029e5 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -435,6 +435,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": "检查更新", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 1fb7eeff2..f9f53f040 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -435,6 +435,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": "檢查更新", diff --git a/lib/page/_shared/models/wifi_radio_ui_model.dart b/lib/page/_shared/models/wifi_radio_ui_model.dart index 4e2865f54..c57976ac6 100644 --- a/lib/page/_shared/models/wifi_radio_ui_model.dart +++ b/lib/page/_shared/models/wifi_radio_ui_model.dart @@ -12,6 +12,14 @@ 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. + final List possibleChannels; + /// Access points grouped under this radio. final List accessPoints; @@ -25,6 +33,7 @@ class WifiRadioUIModel extends Equatable { required this.autoChannelEnable, required this.channelBandwidth, required this.supportedStandards, + this.possibleChannels = const [], this.accessPoints = const [], }); @@ -58,6 +67,7 @@ class WifiRadioUIModel extends Equatable { autoChannelEnable, channelBandwidth, supportedStandards, + possibleChannels, accessPoints, ]; } diff --git a/lib/page/dashboard/views/dialogs/wifi_channel_dialog.dart b/lib/page/dashboard/views/dialogs/wifi_channel_dialog.dart index c6c04033c..de45f506d 100644 --- a/lib/page/dashboard/views/dialogs/wifi_channel_dialog.dart +++ b/lib/page/dashboard/views/dialogs/wifi_channel_dialog.dart @@ -5,7 +5,12 @@ 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 +21,61 @@ 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; + + /// 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()); + _channels = widget.radio.possibleChannels; + // 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; } - @override - void dispose() { - _channelController.dispose(); - super.dispose(); + /// 5 GHz DFS channels (IEEE 802.11h). Used to annotate options with "· DFS". + static const _dfsChannels5 = { + 52, 56, 60, 64, // + 100, 104, 108, 112, 116, 120, 124, 128, 132, 136, 140, 144, + }; + + bool _isDfs(int channel) => + widget.radio.band == '5GHz' && _dfsChannels5.contains(channel); + + 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 +83,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 +130,28 @@ 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() { + 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; + + // AC4: selection equal to the stored value is a no-op — return null so the + // caller issues no mutation. + final unchanged = autoChannel == widget.radio.autoChannelEnable && + (autoChannel || channel == widget.radio.channel); + if (unchanged) { + Navigator.of(context).pop(); + return; + } + + Navigator.of(context).pop((channel: channel, autoChannel: autoChannel)); + } } 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..61698d917 100644 --- a/lib/page/wifi_settings/services/usp_wifi_data_service.dart +++ b/lib/page/wifi_settings/services/usp_wifi_data_service.dart @@ -211,7 +211,7 @@ class UspWifiDataService { }).toList(); return WifiRadioUIModel( instancePath: radio.instancePath, - band: radio.operatingFrequencyBand, + band: _normalizeBand(radio.operatingFrequencyBand), enable: radio.enable, transmitPower: radio.transmitPower, maxBitRate: radio.maxBitRate, @@ -219,6 +219,7 @@ class UspWifiDataService { autoChannelEnable: radio.autoChannelEnable, channelBandwidth: radio.operatingChannelBandwidth, supportedStandards: radio.supportedStandards, + possibleChannels: _parsePossibleChannels(radio.possibleChannels), accessPoints: apModels, ); }).toList(); @@ -416,4 +417,37 @@ class UspWifiDataService { if (lower.contains('2.4') || lower.contains('2_4')) return '2.4GHz'; return rawBand; } + + /// 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] + static 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; + } } diff --git a/pubspec.yaml b/pubspec.yaml index c547cf12f..2696a6513 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -59,11 +59,11 @@ dependencies: ui_kit_library: git: url: https://github.com/linksys/privacyGUI-UI-kit.git - ref: v2.26.0 + ref: v2.26.1 generative_ui: git: url: https://github.com/linksys/privacyGUI-UI-kit.git - ref: v2.26.0 + ref: v2.26.1 path: generative_ui flutter_blue_plus: ^1.4.0 crypto: ^3.0.2 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/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..9d6ef64f6 --- /dev/null +++ b/test/page/dashboard/views/dialogs/wifi_channel_dialog_test.dart @@ -0,0 +1,328 @@ +@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, + 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, + ); +} + +/// 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 defaults to Auto', + (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(); + + // Radio was NOT auto originally, now shows Auto -> this is a real change. + expect(called, isTrue); + expect(captured, isNotNull); + expect(captured!.autoChannel, isTrue); + }); + + 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'); + }); + + // 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/wifi_settings/services/usp_wifi_data_service_test.dart b/test/page/wifi_settings/services/usp_wifi_data_service_test.dart index 79cec7fc7..651bc6b4f 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 @@ -111,6 +111,39 @@ void _stubAllFetches(MockUspClient mockUsp) { }); } +/// Stubs fetches for a single 5 GHz radio whose `PossibleChannels` value is +/// [possibleChannels]. Used to exercise `_parsePossibleChannels` (private) 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; @@ -193,6 +226,17 @@ void main() { expect(radio2.maxBitRate, 2400); }); + 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 +248,56 @@ void main() { expect(result.connectionDetailMap, isEmpty); }); }); + + // ------------------------------------------------------------------------- + // PossibleChannels parsing — range notation, sentinels, malformed tokens + // (W-3 / W-4). Exercises the private _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); + }); + }); } From d1df169d9f0962d76b631a60c54bcc8c7b12980b Mon Sep 17 00:00:00 2001 From: AustinChangLinksys <79675086+AustinChangLinksys@users.noreply.github.com> Date: Fri, 3 Jul 2026 11:39:12 +0800 Subject: [PATCH 10/56] fix(dashboard): unify card rows + navigation fixes (#1017) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(dashboard): unify card rows + navigation fixes (#1014, #1012, #1009, #1002) - Add ToggleRow, NetworkRow, ProtocolBadge components to row_blocks.dart - Refactor Port Forwarding card to use ToggleRow + ProtocolBadge - Refactor DHCP Reservations card to use ToggleRow + DeviceRow - Refactor WiFi Networks card to use NetworkRow component - Add View Details navigation: Topology card → topology, Device Info → node detail - Add Statistics page tab parameter for System Status/Traffic Analysis cards - Remove "off" option from polling interval cards (#1012) - Remove "admin" username from password card (#1009) - Fix uptime not updating by including uptimeSeconds in SystemSnapshot - Fix Network Diagnostics back button returning to dashboard (#1002) - Move diagnostics route as child of menu route - Use context.pop() instead of goNamed for proper back navigation Co-Authored-By: Claude Opus 4.5 * fix: address PR review feedback - Guard instancePath null in DHCP reservation toggle (W-1) - Add canPop() guard before pop() in diagnostics view (W-3) - Guard empty deviceId in device info card footer (W-4) - Replace native widgets with UI Kit components in row_blocks (W-6) - _GuestBadge → AppBadge - _ShareButton → AppIconButton - ProtocolBadge → AppBadge Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- .../components/layout_blocks/row_blocks.dart | 193 ++++++++++++++++++ .../_shared/models/system_monitor_state.dart | 15 ++ .../services/usp_system_monitor_service.dart | 1 + .../admin/cards/usp_device_info_card.dart | 47 ++++- .../views/components/usp_password_card.dart | 8 +- .../orchestrator/dashboard_orchestrator.dart | 1 + .../components/usp_system_status_card.dart | 57 +++++- .../components/usp_traffic_analysis_card.dart | 51 ++++- .../cards/usp_dhcp_reservations_card.dart | 93 +++------ .../cards/usp_ethernet_ports_card.dart | 2 - .../cards/usp_port_forwarding_card.dart | 114 +++-------- .../statistics/views/usp_statistics_view.dart | 10 +- .../cards/usp_network_topology_card.dart | 2 +- .../views/unified_diagnostics_view.dart | 12 +- .../cards/usp_wifi_networks_card.dart | 163 ++------------- .../cards/usp_wifi_performance_card.dart | 2 - lib/route/route_usp_dashboard.dart | 18 +- .../cards/fixtures/cards_test_data.dart | 1 + .../fixtures/statistics_test_data.dart | 1 + .../usp_system_monitor_notifier_test.dart | 2 + 20 files changed, 466 insertions(+), 327 deletions(-) diff --git a/lib/page/_shared/components/layout_blocks/row_blocks.dart b/lib/page/_shared/components/layout_blocks/row_blocks.dart index 1c4578aaa..b6d61c2f1 100644 --- a/lib/page/_shared/components/layout_blocks/row_blocks.dart +++ b/lib/page/_shared/components/layout_blocks/row_blocks.dart @@ -125,3 +125,196 @@ 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. +class ToggleRow extends StatelessWidget { + final bool value; + final ValueChanged? onChanged; + final String title; + final String? subtitle; + final Widget? trailing; + final VoidCallback? onTap; + + const ToggleRow({ + super.key, + required this.value, + this.onChanged, + required this.title, + this.subtitle, + this.trailing, + this.onTap, + }); + + @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: 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. +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; + + const NetworkRow({ + super.key, + required this.ssidName, + required this.bands, + this.isGuest = false, + required this.isEnabled, + required this.clientCount, + this.onChanged, + this.onShareTap, + }); + + @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 (isEnabled && onShareTap != null) ...[ + _ShareButton(onTap: onShareTap!), + AppGap.sm(), + ], + 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/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/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/admin/cards/usp_device_info_card.dart b/lib/page/admin/cards/usp_device_info_card.dart index 6caec4012..372c8787f 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'; @@ -38,7 +39,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 +130,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/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/dashboard/orchestrator/dashboard_orchestrator.dart b/lib/page/dashboard/orchestrator/dashboard_orchestrator.dart index e7b28eea4..4d174dfd0 100644 --- a/lib/page/dashboard/orchestrator/dashboard_orchestrator.dart +++ b/lib/page/dashboard/orchestrator/dashboard_orchestrator.dart @@ -175,6 +175,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/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/local_network/cards/usp_dhcp_reservations_card.dart b/lib/page/local_network/cards/usp_dhcp_reservations_card.dart index 12800a5bd..09ad050f0 100644 --- a/lib/page/local_network/cards/usp_dhcp_reservations_card.dart +++ b/lib/page/local_network/cards/usp_dhcp_reservations_card.dart @@ -70,39 +70,25 @@ 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, + 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 + ? null + : () => _confirmDeleteDhcp(context, ref, reservation), ), ); } @@ -112,33 +98,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.active + ? (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(), 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/port_forwarding/cards/usp_port_forwarding_card.dart b/lib/page/port_forwarding/cards/usp_port_forwarding_card.dart index 31e2162e7..912d4f3e0 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,41 @@ 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, + onChanged: isLoading + ? 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, + onChanged: isLoading + ? 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), ); } @@ -169,24 +136,3 @@ class UspPortForwardingCard extends ConsumerWidget { ); } } - -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, - ), - ); - } -} 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/topology/cards/usp_network_topology_card.dart b/lib/page/topology/cards/usp_network_topology_card.dart index 37b9005dc..d9aef7d3a 100644 --- a/lib/page/topology/cards/usp_network_topology_card.dart +++ b/lib/page/topology/cards/usp_network_topology_card.dart @@ -55,7 +55,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/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/wifi_settings/cards/usp_wifi_networks_card.dart b/lib/page/wifi_settings/cards/usp_wifi_networks_card.dart index 6770b755d..b1548bf79 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'; @@ -93,159 +94,19 @@ class UspWifiNetworksCard extends ConsumerWidget { WidgetRef ref, _WifiNetworkEntry network, ) { - 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, + 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..bc51176c5 100644 --- a/lib/page/wifi_settings/cards/usp_wifi_performance_card.dart +++ b/lib/page/wifi_settings/cards/usp_wifi_performance_card.dart @@ -10,7 +10,6 @@ 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. @@ -54,7 +53,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, diff --git a/lib/route/route_usp_dashboard.dart b/lib/route/route_usp_dashboard.dart index 6a0993268..365932305 100644 --- a/lib/route/route_usp_dashboard.dart +++ b/lib/route/route_usp_dashboard.dart @@ -24,6 +24,13 @@ final uspDashboardRoute = ShellRoute( 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 +109,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 +190,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/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..217a04c98 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 @@ -624,6 +624,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/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/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, )); } From d7a9cc330af613c4b68e6104e5a479cd579fd3b3 Mon Sep 17 00:00:00 2001 From: Austin Chang Date: Thu, 2 Jul 2026 14:57:51 +0800 Subject: [PATCH 11/56] fix(dashboard): consistent device counts excluding mesh nodes (#1020, #1022, #1024) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issues fixed: - #1020: Dashboard "Devices" stat now uses clientDevices (excludes routers) - #1022: Device Analytics excludes mesh nodes from counts and activity heatmap - #1024: Network Topology signal indicator now uses 1:1 RSSI→LinkQuality mapping consistent with UspSignalStrengthIndicator (getWifiSignalLevel SSoT) Key changes: - Use clientDevices instead of deviceModels across dashboard, mascot triggers, PDF export, and feature dropdowns (port forwarding, DHCP, IPv6 port service) - Add serial number scoping to analytics persistence to prevent data mixing - Filter router MACs from persisted history on load (legacy data cleanup) - Fix _rssiToLinkQuality() mapping: good→good, fair→fair, poor→unknown Co-Authored-By: Claude Opus 4.5 --- .../device_analytics_persistence.dart | 26 ++- .../usp_device_analytics_notifier.dart | 87 +++++++-- .../_shared/services/usp_pdf_service.dart | 6 +- .../triggers/mascot_trigger_provider.dart | 12 +- .../views/components/usp_stats_panel.dart | 2 +- .../usp_dhcp_reservations_detail_card.dart | 2 +- .../views/usp_ipv6_port_service_view.dart | 2 +- .../views/components/usp_single_port_tab.dart | 2 +- .../helpers/usp_topology_builder.dart | 17 +- .../topology/views/usp_topology_view.dart | 6 +- .../usp_device_analytics_notifier_test.dart | 169 +++++++++++++++++- .../helpers/usp_topology_builder_test.dart | 12 +- 12 files changed, 294 insertions(+), 49 deletions(-) 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..2b2c07c43 100644 --- a/lib/page/_shared/providers/usp_device_analytics_notifier.dart +++ b/lib/page/_shared/providers/usp_device_analytics_notifier.dart @@ -3,6 +3,7 @@ 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/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,29 @@ 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(); - // Listen to device data changes for future updates ref.listen(devicesDataProvider, (previous, next) { final data = next.valueOrNull; if (data == null) return; - _onDashboardUpdated(data.deviceModels); + _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,21 +47,71 @@ 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'); } } + /// Returns the set of router MACs (master + slave nodes). + Set _getRouterMacs() { + final allDeviceModels = + ref.read(devicesDataProvider).valueOrNull?.deviceModels ?? []; + return allDeviceModels + .where((d) => d.deviceRole == 'master' || d.deviceRole == 'slave') + .map((d) => d.mac) + .toSet(); + } + void _onDashboardUpdated(List devices) { // 1. Compute current distribution final distribution = _computeDistribution(devices); @@ -97,10 +153,13 @@ 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 + // Get router MACs to filter out from history (mesh nodes should not appear) + final routerMacs = _getRouterMacs(); + + // Rebuild allKnownMacs from history, excluding router MACs final allMacs = {}; for (final h in history) { - allMacs.addAll(h.activeMacs); + allMacs.addAll(h.activeMacs.where((mac) => !routerMacs.contains(mac))); } state = state.copyWith( @@ -165,7 +224,7 @@ class UspDeviceAnalyticsNotifier extends Notifier { Future _persistState() async { 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..1bcd72c84 100644 --- a/lib/page/_shared/services/usp_pdf_service.dart +++ b/lib/page/_shared/services/usp_pdf_service.dart @@ -352,7 +352,11 @@ class UspPdfService { // =========================================================================== static List _buildDevices(PdfReportData data) { - final devices = data.deviceModels ?? []; + final allDevices = data.deviceModels ?? []; + // Exclude mesh nodes (routers) — only show client devices in report + final devices = allDevices + .where((d) => d.deviceRole != 'master' && d.deviceRole != 'slave') + .toList(); final online = devices.where((d) => d.isActive).toList(); final offline = devices.where((d) => !d.isActive).toList(); 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/views/components/usp_stats_panel.dart b/lib/page/dashboard/views/components/usp_stats_panel.dart index 6d998f51c..ee0290043 100644 --- a/lib/page/dashboard/views/components/usp_stats_panel.dart +++ b/lib/page/dashboard/views/components/usp_stats_panel.dart @@ -20,7 +20,7 @@ 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 wifiData = ref.watch(wifiDataProvider).valueOrNull; 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..e6ae9679d 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 @@ -108,7 +108,7 @@ class UspDhcpReservationsDetailCard extends ConsumerWidget { ({List mac, List ip}) _buildDeviceOptions(WidgetRef ref) { final devices = - ref.read(devicesDataProvider).valueOrNull?.deviceModels ?? []; + ref.read(devicesDataProvider).valueOrNull?.clientDevices ?? []; final macOptions = devices .where((d) => d.mac.isNotEmpty) .map((d) => AppAutoCompleteOption( 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 f337236fd..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 @@ -192,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/port_forwarding/views/components/usp_single_port_tab.dart b/lib/page/port_forwarding/views/components/usp_single_port_tab.dart index 345fe6437..07b455b7a 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 @@ -101,7 +101,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/topology/helpers/usp_topology_builder.dart b/lib/page/topology/helpers/usp_topology_builder.dart index 66464e91d..d991f23c5 100644 --- a/lib/page/topology/helpers/usp_topology_builder.dart +++ b/lib/page/topology/helpers/usp_topology_builder.dart @@ -260,19 +260,22 @@ class UspTopologyBuilder { /// 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, }; diff --git a/lib/page/topology/views/usp_topology_view.dart b/lib/page/topology/views/usp_topology_view.dart index 9fdc01afe..8949c99ae 100644 --- a/lib/page/topology/views/usp_topology_view.dart +++ b/lib/page/topology/views/usp_topology_view.dart @@ -60,16 +60,14 @@ class _UspTopologyViewState extends ConsumerState { 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/test/page/_shared/providers/usp_device_analytics_notifier_test.dart b/test/page/_shared/providers/usp_device_analytics_notifier_test.dart index 0fb8a898e..15c2e2be9 100644 --- a/test/page/_shared/providers/usp_device_analytics_notifier_test.dart +++ b/test/page/_shared/providers/usp_device_analytics_notifier_test.dart @@ -3,7 +3,9 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.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/system_info_ui_model.dart'; import 'package:privacy_gui/page/_shared/providers/usp_device_analytics_notifier.dart'; +import 'package:privacy_gui/page/admin/providers/system_info_data_provider.dart'; import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; /// Test-only devices data notifier returning canned data. @@ -19,6 +21,29 @@ class _TestDevicesDataNotifier extends DevicesDataNotifier { } } +/// Test-only system info notifier returning canned data. +class _TestSystemInfoDataNotifier extends SystemInfoDataNotifier { + final String serialNumber; + _TestSystemInfoDataNotifier({this.serialNumber = 'TEST_SN_001'}); + + @override + Future build() async { + return SystemInfoData( + model: SystemInfoUIModel( + modelName: 'TestRouter', + hardwareVersion: '1.0', + manufacturer: 'Test', + serialNumber: serialNumber, + softwareVersion: '1.0.0', + uptime: 3600, + totalMemory: 512000, + freeMemory: 256000, + cpuUsage: 25, + ), + ); + } +} + void main() { const wifiDevice5g = DeviceUIModel( mac: 'AA:BB:CC:DD:EE:01', @@ -65,13 +90,18 @@ void main() { SharedPreferences.setMockInitialValues({}); }); - ProviderContainer createContainer( - {DevicesData? data, bool shouldThrow = false}) { + ProviderContainer createContainer({ + DevicesData? data, + bool shouldThrow = false, + String serialNumber = 'TEST_SN_001', + }) { final devicesData = data ?? testDevicesData; final container = ProviderContainer( overrides: [ devicesDataProvider.overrideWith(() => _TestDevicesDataNotifier(devicesData, shouldThrow: shouldThrow)), + systemInfoDataProvider.overrideWith( + () => _TestSystemInfoDataNotifier(serialNumber: serialNumber)), ], ); return container; @@ -244,6 +274,52 @@ void main() { container.dispose(); }); + test('excludes mesh nodes from distribution', () async { + // Add mesh nodes (master and slave routers) to the device list + const masterNode = DeviceUIModel( + mac: 'AA:BB:CC:DD:EE:05', + ip: '192.168.1.1', + hostName: 'Router', + isActive: true, + isWifi: false, + deviceRole: 'master', + ); + const slaveNode = DeviceUIModel( + mac: 'AA:BB:CC:DD:EE:06', + ip: '192.168.1.2', + hostName: 'Extender', + isActive: true, + isWifi: true, + band: '5GHz', + signalStrength: -50, + deviceRole: 'slave', + ); + // Include mesh nodes alongside regular client devices + final dataWithMesh = DevicesData( + deviceModels: [...testDevices, masterNode, slaveNode], + ); + final container = createContainer(data: dataWithMesh); + await waitForAnalytics(container); + + final state = container.read(uspDeviceAnalyticsProvider); + expect(state.current, isNotNull); + + final dist = state.current!; + // Mesh nodes should NOT be counted — same as without them + // 2 wifi online + 1 wired online = 3 online, 1 offline (no mesh nodes) + expect(dist.onlineCount, 3); + expect(dist.offlineCount, 1); + expect(dist.wifiCount, 2); + expect(dist.wiredCount, 1); + expect(dist.totalCount, 4); + + // Band distribution should NOT include the slave's 5GHz + expect(dist.bandDistribution['5GHz'], 1); // Only wifiDevice5g + expect(dist.bandDistribution['2.4GHz'], 1); + expect(dist.bandDistribution['Wired'], 1); + container.dispose(); + }); + test('band signal quality computes average per band', () async { // Two 5GHz devices with different signal strengths const wifi5a = DeviceUIModel( @@ -273,5 +349,94 @@ void main() { expect(dist.bandSignalQuality['5GHz'], closeTo(0.5, 0.01)); container.dispose(); }); + + test('filters router MACs from persisted history on load', () async { + // Simulate legacy persisted data that includes router MACs + final now = DateTime.now(); + final currentHour = DateTime(now.year, now.month, now.day, now.hour); + const routerMac = 'AA:BB:CC:DD:EE:05'; // Will be marked as master + const clientMac = 'AA:BB:CC:DD:EE:01'; // Regular client + + final legacyState = DeviceAnalyticsState( + hourlyHistory: [ + HourlyAggregate( + hour: currentHour.subtract(Duration(hours: 1)), + wifiCount: 2, + wiredCount: 0, + activeMacs: {routerMac, clientMac}, // Legacy: contains router MAC + ), + ], + allKnownMacs: {routerMac, clientMac}, + macDisplayNames: {routerMac: 'Router', clientMac: 'Phone'}, + ); + + // Pre-populate SharedPreferences with legacy data + SharedPreferences.setMockInitialValues({ + 'flutter.usp_device_analytics_LEGACY_SN': legacyState.toJsonString(), + }); + + // Create container with a mesh node that has the router MAC + const masterNode = DeviceUIModel( + mac: routerMac, + ip: '192.168.1.1', + hostName: 'Router', + isActive: true, + isWifi: false, + deviceRole: 'master', + ); + final dataWithRouter = DevicesData( + deviceModels: [wifiDevice5g, masterNode], + ); + + final container = createContainer( + data: dataWithRouter, + serialNumber: 'LEGACY_SN', + ); + await waitForAnalytics(container); + + final state = container.read(uspDeviceAnalyticsProvider); + + // Router MAC should be filtered out from allKnownMacs + expect(state.allKnownMacs, isNot(contains(routerMac))); + expect(state.allKnownMacs, contains(clientMac)); + + // Hourly history activeMacs should also exclude router MAC + for (final h in state.hourlyHistory) { + expect(h.activeMacs, isNot(contains(routerMac))); + } + + container.dispose(); + }); + + test('persistence is scoped by router serial number', () async { + // First router with SN "ROUTER_A" + final containerA = createContainer(serialNumber: 'ROUTER_A'); + await waitForAnalytics(containerA); + final stateA = containerA.read(uspDeviceAnalyticsProvider); + expect(stateA.hourlyHistory, hasLength(1)); + containerA.dispose(); + + // Second router with different SN "ROUTER_B" + final containerB = createContainer( + data: const DevicesData(deviceModels: []), + serialNumber: 'ROUTER_B', + ); + await waitForAnalytics(containerB); + final stateB = containerB.read(uspDeviceAnalyticsProvider); + // Should NOT inherit history from Router A — different SN means different key + expect(stateB.hourlyHistory, hasLength(1)); // Only its own empty entry + expect(stateB.current!.onlineCount, 0); // Empty device list + containerB.dispose(); + + // Back to Router A — should still have its data + final containerA2 = createContainer(serialNumber: 'ROUTER_A'); + await waitForAnalytics(containerA2); + final stateA2 = containerA2.read(uspDeviceAnalyticsProvider); + // Should have 2 entries now (original + this session's update) + expect(stateA2.hourlyHistory.isNotEmpty, isTrue); + expect( + stateA2.current!.onlineCount, 3); // Has devices from testDevicesData + containerA2.dispose(); + }); }); } diff --git a/test/page/topology/helpers/usp_topology_builder_test.dart b/test/page/topology/helpers/usp_topology_builder_test.dart index dc40738f8..42622fd16 100644 --- a/test/page/topology/helpers/usp_topology_builder_test.dart +++ b/test/page/topology/helpers/usp_topology_builder_test.dart @@ -357,7 +357,7 @@ void main() { test('medium wifi signal maps to medium level', () { // wifi.dart thresholds: [-65, -71, -78] - // -75 is >= -78 (fair) → level 0.4, LinkQuality.good + // -75 is >= -78 (fair) → level 0.4, LinkQuality.fair const device = DeviceUIModel( mac: 'AA:AA:AA:AA:AA:AA', ip: '192.168.1.1', @@ -376,12 +376,12 @@ void main() { final client = topo.nodes.firstWhere((n) => n.type == MeshNodeType.client); expect(client.level, 0.4); - expect(client.linkQuality, LinkQuality.good); + expect(client.linkQuality, LinkQuality.fair); }); 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 + // -68 is in (-71, -65] → good → level 0.65, LinkQuality.good const device = DeviceUIModel( mac: 'AA:AA:AA:AA:AA:AB', ip: '192.168.1.1', @@ -400,12 +400,12 @@ void main() { final client = topo.nodes.firstWhere((n) => n.type == MeshNodeType.client); expect(client.level, 0.65); - expect(client.linkQuality, LinkQuality.excellent); + expect(client.linkQuality, LinkQuality.good); }); test('weak wifi signal maps to low level', () { // wifi.dart thresholds: [-65, -71, -78] - // -80 is < -78 (poor) → LinkQuality.fair + // -80 is < -78 (poor) → LinkQuality.unknown const device = DeviceUIModel( mac: 'AA:AA:AA:AA:AA:AA', ip: '192.168.1.1', @@ -424,7 +424,7 @@ void main() { final client = topo.nodes.firstWhere((n) => n.type == MeshNodeType.client); expect(client.level, 0.1); - expect(client.linkQuality, LinkQuality.fair); + expect(client.linkQuality, LinkQuality.unknown); }); test('ethernet device maps to wired signal quality and level 1.0', () { From 6cbfd7f1da995ae01c7dba387d162fae7ed0a540 Mon Sep 17 00:00:00 2001 From: Austin Chang Date: Thu, 2 Jul 2026 15:23:13 +0800 Subject: [PATCH 12/56] =?UTF-8?q?test(topology):=20add=20defensive=20test?= =?UTF-8?q?=20for=20RSSI=E2=86=92LinkQuality=20SSoT=20consistency=20(#1024?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ensures UspTopologyBuilder's LinkQuality mapping stays in sync with getWifiSignalLevel() from wifi.dart. Tests all RSSI boundary values to catch future mapping drift that caused #1024. Co-Authored-By: Claude Opus 4.5 --- .../helpers/usp_topology_builder_test.dart | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/test/page/topology/helpers/usp_topology_builder_test.dart b/test/page/topology/helpers/usp_topology_builder_test.dart index 42622fd16..8503f4446 100644 --- a/test/page/topology/helpers/usp_topology_builder_test.dart +++ b/test/page/topology/helpers/usp_topology_builder_test.dart @@ -1,6 +1,7 @@ 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/core/utils/wifi.dart'; import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; import 'package:privacy_gui/page/_shared/models/system_info_ui_model.dart'; import 'package:privacy_gui/page/topology/models/node_ui_model.dart'; @@ -461,6 +462,56 @@ void main() { expect(client.level, 0.0); expect(client.linkQuality, LinkQuality.unknown); }); + + test('LinkQuality mapping is consistent with getWifiSignalLevel SSoT', () { + // Defense test: ensures topology LinkQuality stays in sync with + // getWifiSignalLevel() from wifi.dart — the single source of truth. + // If this test fails, someone changed the mapping without updating + // both places, causing #1024-like inconsistencies. + + // Test boundary RSSI values for each threshold + const testCases = [ + // (rssi, expectedLevel, expectedLinkQuality) + (-64, NodeSignalLevel.excellent, LinkQuality.excellent), // >= -65 + (-65, NodeSignalLevel.excellent, LinkQuality.excellent), // exactly -65 + (-66, NodeSignalLevel.good, LinkQuality.good), // < -65, >= -71 + (-71, NodeSignalLevel.good, LinkQuality.good), // exactly -71 + (-72, NodeSignalLevel.fair, LinkQuality.fair), // < -71, >= -78 + (-78, NodeSignalLevel.fair, LinkQuality.fair), // exactly -78 + (-79, NodeSignalLevel.poor, LinkQuality.unknown), // < -78 + (-90, NodeSignalLevel.poor, LinkQuality.unknown), // very weak + ]; + + for (final (rssi, expectedSignalLevel, expectedLinkQuality) + in testCases) { + // Verify getWifiSignalLevel returns expected level + final actualSignalLevel = getWifiSignalLevel(rssi); + expect(actualSignalLevel, expectedSignalLevel, + reason: 'getWifiSignalLevel($rssi) should be $expectedSignalLevel'); + + // Verify topology builder produces consistent LinkQuality + final device = DeviceUIModel( + mac: 'AA:AA:AA:AA:AA:AA', + ip: '192.168.1.1', + hostName: 'Test', + isActive: true, + isWifi: true, + signalStrength: rssi, + ); + + final topo = UspTopologyBuilder.build( + info: sysInfo, + devices: [device], + nodeModels: [], + ); + + final client = + topo.nodes.firstWhere((n) => n.type == MeshNodeType.client); + expect(client.linkQuality, expectedLinkQuality, + reason: 'RSSI $rssi (${actualSignalLevel.name}) should map to ' + '$expectedLinkQuality, but got ${client.linkQuality}'); + } + }); }); // --------------------------------------------------------------------------- From d43a10e9becd90b82e8adb17e8eadaec5af84d8c Mon Sep 17 00:00:00 2001 From: Austin Chang Date: Thu, 2 Jul 2026 15:40:48 +0800 Subject: [PATCH 13/56] fix(dashboard): child node client signal + trend Y-axis (#1043, #1044) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1044: Clients connected to child mesh nodes now show signal strength. - Add `clientSignalMap` to MeshTopologyInfo (MAC → RSSI from DataElements) - MeshTopologyBuilder extracts STA.SignalStrength (RCPI→RSSI conversion) - Use as fallback in _toDeviceUIModel when WifiClients has no data #1043: Trend chart Y-axis no longer shows duplicate numbers. - Add explicit yAxis with calculated max and interval - Ensures clean labels when device count is small (e.g., 0, 1, 2) Co-Authored-By: Claude Opus 4.5 --- lib/page/_shared/models/mesh_topology_info.dart | 16 ++++++++++++++-- .../_shared/utils/mesh_topology_builder.dart | 17 ++++++++++++++--- .../components/usp_device_analytics_card.dart | 7 +++++++ .../services/usp_devices_data_service.dart | 11 ++++++++--- 4 files changed, 43 insertions(+), 8 deletions(-) diff --git a/lib/page/_shared/models/mesh_topology_info.dart b/lib/page/_shared/models/mesh_topology_info.dart index 4aa2679aa..f1f3d36d7 100644 --- a/lib/page/_shared/models/mesh_topology_info.dart +++ b/lib/page/_shared/models/mesh_topology_info.dart @@ -12,17 +12,29 @@ class MeshTopologyInfo extends Equatable { /// 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; + const MeshTopologyInfo({ required this.nodes, required this.clientToNodeMap, + this.clientSignalMap = const {}, }); /// Empty result — used as fallback when DataElements is not supported. - static const empty = MeshTopologyInfo(nodes: [], clientToNodeMap: {}); + static const empty = MeshTopologyInfo( + nodes: [], + clientToNodeMap: {}, + clientSignalMap: {}, + ); bool get isEmpty => nodes.isEmpty; bool get isNotEmpty => nodes.isNotEmpty; @override - List get props => [nodes, clientToNodeMap]; + List get props => [nodes, clientToNodeMap, clientSignalMap]; } diff --git a/lib/page/_shared/utils/mesh_topology_builder.dart b/lib/page/_shared/utils/mesh_topology_builder.dart index 195032254..5088bb12c 100644 --- a/lib/page/_shared/utils/mesh_topology_builder.dart +++ b/lib/page/_shared/utils/mesh_topology_builder.dart @@ -23,18 +23,25 @@ class MeshTopologyBuilder { }) { final nodes = []; final clientToNodeMap = {}; + final clientSignalMap = {}; 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 and signal strength from station list for (final radio in node.radios) { for (final bss in radio.bssList) { 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; + } } } } @@ -88,6 +95,10 @@ class MeshTopologyBuilder { )); } - return MeshTopologyInfo(nodes: nodes, clientToNodeMap: clientToNodeMap); + return MeshTopologyInfo( + nodes: nodes, + clientToNodeMap: clientToNodeMap, + clientSignalMap: clientSignalMap, + ); } } 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..a56e56856 100644 --- a/lib/page/dashboard/views/components/usp_device_analytics_card.dart +++ b/lib/page/dashboard/views/components/usp_device_analytics_card.dart @@ -269,6 +269,12 @@ class _TrendView extends StatelessWidget { (s) => s.hour.hour % 3 == 0 ? '${s.hour.hour}'.padLeft(2, '0') : '') .toList(); + // Calculate Y-axis bounds to avoid duplicate labels when count is small + final maxCount = + slots.map((s) => s.wifi + s.wired).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( @@ -285,6 +291,7 @@ class _TrendView extends StatelessWidget { ], stacked: true, xLabels: xLabels, + yAxis: AppChartAxis(min: 0, max: yMax, interval: yInterval), showTooltip: false, ), ), diff --git a/lib/page/devices/services/usp_devices_data_service.dart b/lib/page/devices/services/usp_devices_data_service.dart index 41cd76df1..20fae0562 100644 --- a/lib/page/devices/services/usp_devices_data_service.dart +++ b/lib/page/devices/services/usp_devices_data_service.dart @@ -491,9 +491,14 @@ class UspDevicesDataService { .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, + // Prefer Hosts data, fallback to WiFi STA table, then DataElements. + // DataElements provides signal for clients on ALL nodes (including child nodes), + // while WifiClients only covers master node clients. + signalStrength: isWifi + ? (device.signalStrength ?? + wifiClient?.signalStrength ?? + meshTopology.clientSignalMap[mac]) + : null, downlinkRate: isWifi ? (device.lastDataDownlinkRate ?? wifiClient?.lastDataDownlinkRate) : null, From 99f1bace63e38fd7c924c3d22f192955adab438c Mon Sep 17 00:00:00 2001 From: Austin Chang Date: Fri, 3 Jul 2026 12:44:30 +0800 Subject: [PATCH 14/56] fix(analytics): address review feedback for device analytics (#1053) - Guard _persistState() against race condition: don't persist before _historyLoaded is set (avoids writing to legacy key when _serialNumber is still null) - Reset instance state (_historyLoaded, _serialNumber) in build() to handle provider invalidation correctly - Use isMeshNode getter instead of raw deviceRole string comparison in _getRouterMacs() for SSoT consistency - Use isClientDevice getter instead of raw deviceRole string comparison in usp_pdf_service.dart for SSoT consistency Co-Authored-By: Claude Opus 4.5 --- .../_shared/providers/usp_device_analytics_notifier.dart | 9 ++++++++- lib/page/_shared/services/usp_pdf_service.dart | 4 +--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/lib/page/_shared/providers/usp_device_analytics_notifier.dart b/lib/page/_shared/providers/usp_device_analytics_notifier.dart index 2b2c07c43..754d95b87 100644 --- a/lib/page/_shared/providers/usp_device_analytics_notifier.dart +++ b/lib/page/_shared/providers/usp_device_analytics_notifier.dart @@ -25,6 +25,10 @@ class UspDeviceAnalyticsNotifier extends Notifier { @override DeviceAnalyticsState build() { + // 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; @@ -107,7 +111,7 @@ class UspDeviceAnalyticsNotifier extends Notifier { final allDeviceModels = ref.read(devicesDataProvider).valueOrNull?.deviceModels ?? []; return allDeviceModels - .where((d) => d.deviceRole == 'master' || d.deviceRole == 'slave') + .where((d) => d.isMeshNode) .map((d) => d.mac) .toSet(); } @@ -223,6 +227,9 @@ class UspDeviceAnalyticsNotifier extends Notifier { } 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, serialNumber: _serialNumber); } catch (e) { diff --git a/lib/page/_shared/services/usp_pdf_service.dart b/lib/page/_shared/services/usp_pdf_service.dart index 1bcd72c84..90637ff38 100644 --- a/lib/page/_shared/services/usp_pdf_service.dart +++ b/lib/page/_shared/services/usp_pdf_service.dart @@ -354,9 +354,7 @@ class UspPdfService { static List _buildDevices(PdfReportData data) { final allDevices = data.deviceModels ?? []; // Exclude mesh nodes (routers) — only show client devices in report - final devices = allDevices - .where((d) => d.deviceRole != 'master' && d.deviceRole != 'slave') - .toList(); + final devices = allDevices.where((d) => d.isClientDevice).toList(); final online = devices.where((d) => d.isActive).toList(); final offline = devices.where((d) => !d.isActive).toList(); From 080a1447563ac731c7b6501815f975475508a5c9 Mon Sep 17 00:00:00 2001 From: Austin Chang Date: Fri, 3 Jul 2026 12:47:31 +0800 Subject: [PATCH 15/56] style: dart format usp_device_analytics_notifier.dart --- .../_shared/providers/usp_device_analytics_notifier.dart | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/page/_shared/providers/usp_device_analytics_notifier.dart b/lib/page/_shared/providers/usp_device_analytics_notifier.dart index 754d95b87..c15c5a407 100644 --- a/lib/page/_shared/providers/usp_device_analytics_notifier.dart +++ b/lib/page/_shared/providers/usp_device_analytics_notifier.dart @@ -110,10 +110,7 @@ class UspDeviceAnalyticsNotifier extends Notifier { Set _getRouterMacs() { final allDeviceModels = ref.read(devicesDataProvider).valueOrNull?.deviceModels ?? []; - return allDeviceModels - .where((d) => d.isMeshNode) - .map((d) => d.mac) - .toSet(); + return allDeviceModels.where((d) => d.isMeshNode).map((d) => d.mac).toSet(); } void _onDashboardUpdated(List devices) { From 394bf3bcab0a0bab31124265b6ed499704f9ee20 Mon Sep 17 00:00:00 2001 From: AustinChangLinksys Date: Fri, 3 Jul 2026 13:34:07 +0800 Subject: [PATCH 16/56] fix(analytics): set _historyLoaded=true on load failure to unblock persistence (#1053) W-NEW-1: _loadPersistedHistory() only set _historyLoaded=true inside the try block. Any exception (SharedPreferences cold-start race, corrupted JSON, etc.) left it false, and the PR's new '_persistState() { if (!_historyLoaded) return; }' guard then silently dropped every subsequent write for the provider's lifetime. Set the flag in the catch block so a one-off load failure no longer permanently gates persistence. Refs #1053 --- .../_shared/providers/usp_device_analytics_notifier.dart | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/page/_shared/providers/usp_device_analytics_notifier.dart b/lib/page/_shared/providers/usp_device_analytics_notifier.dart index c15c5a407..20f44aea4 100644 --- a/lib/page/_shared/providers/usp_device_analytics_notifier.dart +++ b/lib/page/_shared/providers/usp_device_analytics_notifier.dart @@ -103,6 +103,11 @@ class UspDeviceAnalyticsNotifier extends Notifier { } 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; } } From 8fc2a408bc541e586a9bd0226bae966fe4e23872 Mon Sep 17 00:00:00 2001 From: Austin Chang Date: Tue, 30 Jun 2026 14:32:36 +0800 Subject: [PATCH 17/56] feat(auth): replace password storage with session token persistence Security improvement: password is no longer stored locally. Instead, session token is persisted in sessionStorage (cleared on browser close) and used for session restoration via refreshToken(token?) API. Key changes: - Add UspTokenStorage with Web (sessionStorage) and stub implementations - UspAuthCoordinator.restoreSession() uses token-only strategy - Add reloginWithNewPassword() for admin password change flow - Add isRecovering flag to suppress force logout during recovery - Remove localPassword from AuthState - Update WASM client to usp-client v0.12.0 with refreshToken(token?) Test coverage: 47 tests for UspAuthCoordinator including: - reloginWithNewPassword flow and error handling - Null UspClient guards for all public methods - Token persistence and restoration scenarios Co-Authored-By: Claude Opus 4.5 --- lib/constants/pref_key.dart | 1 - .../services/recovery_probe_service.dart | 2 +- .../usp/providers/usp_auth_coordinator.dart | 183 +++++-- lib/core/usp/providers/usp_token_storage.dart | 44 ++ .../usp/providers/usp_token_storage_stub.dart | 15 + .../usp/providers/usp_token_storage_web.dart | 72 +++ lib/core/usp/services/usp_client.dart | 4 +- lib/core/usp/stub/usp_client_stub.dart | 2 +- lib/core/usp/web/usp_client_wasm.dart | 6 +- lib/demo/providers/demo_overrides.dart | 16 +- lib/demo/usp/demo_usp_service.dart | 2 +- .../admin/providers/usp_admin_notifier.dart | 7 + .../instant_setup/providers/pnp_notifier.dart | 4 +- lib/providers/auth/auth_provider.dart | 62 +-- lib/providers/auth/auth_service.dart | 74 +-- lib/providers/auth/auth_state.dart | 10 +- .../integration/recovery_flow_test.dart | 5 +- .../app_connection_state_provider_test.dart | 5 +- .../services/recovery_probe_service_test.dart | 23 +- .../providers/usp_auth_coordinator_test.dart | 471 ++++++++++++------ .../providers/usp_admin_notifier_test.dart | 8 + .../firmware_update_notifier_test.dart | 5 +- test/providers/auth/auth_notifier_test.dart | 125 +---- test/providers/auth/auth_service_test.dart | 155 +----- test/providers/auth/auth_state_test.dart | 23 +- web/usp_client.js | 52 +- web/usp_client_bg.wasm | Bin 508708 -> 511251 bytes 27 files changed, 745 insertions(+), 631 deletions(-) create mode 100644 lib/core/usp/providers/usp_token_storage.dart create mode 100644 lib/core/usp/providers/usp_token_storage_stub.dart create mode 100644 lib/core/usp/providers/usp_token_storage_web.dart 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/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/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..b04d80851 --- /dev/null +++ b/lib/core/usp/providers/usp_token_storage_web.dart @@ -0,0 +1,72 @@ +// ignore: avoid_web_libraries_in_flutter +import 'dart:js_interop'; +import 'dart:js_interop_unsafe'; + +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, etc.) + } + } + + @override + String? load() { + try { + return _sessionStorage?.getItem(_key); + } catch (e) { + return null; + } + } + + @override + void clear() { + try { + _sessionStorage?.removeItem(_key); + } catch (e) { + // Ignore errors + } + } + + /// 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/usp_client.dart b/lib/core/usp/services/usp_client.dart index b64d5a3b6..188a883e4 100644 --- a/lib/core/usp/services/usp_client.dart +++ b/lib/core/usp/services/usp_client.dart @@ -142,8 +142,8 @@ class UspClient { await _client.logout(); } - Future refreshToken() async { - await _client.refreshToken(); + Future refreshToken({String? token}) async { + await _client.refreshToken(token: token); } // =========================================================================== 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/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/page/admin/providers/usp_admin_notifier.dart b/lib/page/admin/providers/usp_admin_notifier.dart index 5087afce3..e85b460a1 100644 --- a/lib/page/admin/providers/usp_admin_notifier.dart +++ b/lib/page/admin/providers/usp_admin_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/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'; @@ -43,6 +44,12 @@ class UspAdminNotifier extends AutoDisposeAsyncNotifier { newPassword: newPassword, ); }); + + // Re-authenticate with new password to get a fresh token. + // The old token may be invalidated by the router after password change. + await ref + .read(uspAuthCoordinatorProvider) + .reloginWithNewPassword(newPassword); } on ServiceError catch (e) { logger.e('[USP][Admin]: Password update failed', error: e); rethrow; diff --git a/lib/page/instant_setup/providers/pnp_notifier.dart b/lib/page/instant_setup/providers/pnp_notifier.dart index 2bb236f82..e77e9b145 100644 --- a/lib/page/instant_setup/providers/pnp_notifier.dart +++ b/lib/page/instant_setup/providers/pnp_notifier.dart @@ -316,7 +316,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; diff --git a/lib/providers/auth/auth_provider.dart b/lib/providers/auth/auth_provider.dart index 296c5643b..1f2d0d809 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,21 @@ 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, - ); + // Try to restore USP session from stored token (sessionStorage) + final coordinator = ref.read(uspAuthCoordinatorProvider); + await coordinator.restoreSession(); - logger.d( - '[Auth]init: hasPassword=${localPassword != null}, loginType=$loginType'); + // Determine login type based on whether session was restored + final isAuthenticated = + ref.read(uspClientProvider)?.isAuthenticated ?? false; + final loginType = isAuthenticated ? LoginType.local : LoginType.none; - // Restore USP session on page reload / app restart (local login only) - if (loginType == LoginType.local) { - await ref.read(uspAuthCoordinatorProvider).restoreSession(); - } + 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 +78,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 +90,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 +99,6 @@ class AuthNotifier extends AsyncNotifier { .fetchDeviceInfoAndInitializeServices(); state = AsyncValue.data(previousState.copyWith( - localPassword: password, loginType: LoginType.local, )); } catch (e, st) { @@ -164,24 +152,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..c1d57a23e 100644 --- a/lib/providers/auth/auth_state.dart +++ b/lib/providers/auth/auth_state.dart @@ -5,13 +5,12 @@ 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; @@ -19,7 +18,6 @@ class AuthState extends Equatable { final LoginType loginType; const AuthState({ - this.localPassword, this.localPasswordHint, required this.loginType, }); @@ -35,7 +33,6 @@ class AuthState extends Equatable { LoginType.values.firstWhereOrNull((e) => e.name == json['loginType']) ?? LoginType.none; return AuthState( - localPassword: json['localPassword'], localPasswordHint: json['localPasswordHint'], loginType: loginType, ); @@ -43,12 +40,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 +51,6 @@ class AuthState extends Equatable { @override List get props => [ - localPassword, localPasswordHint, loginType, ]; 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/page/admin/providers/usp_admin_notifier_test.dart b/test/page/admin/providers/usp_admin_notifier_test.dart index 301c50776..612b3ad65 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'; @@ -15,6 +16,8 @@ class MockUspClient extends Mock implements UspClient {} class MockUspAdminService extends Mock implements UspAdminService {} +class MockUspAuthCoordinator extends Mock implements UspAuthCoordinator {} + /// Test-only time data notifier returning canned data. class _TestTimeDataNotifier extends TimeDataNotifier { final TimeData _data; @@ -27,6 +30,7 @@ class _TestTimeDataNotifier extends TimeDataNotifier { void main() { late MockUspClient mockUsp; late MockUspAdminService mockAdminService; + late MockUspAuthCoordinator mockAuthCoordinator; late _TestTimeDataNotifier testTimeNotifier; const testAdmin = AdminUserUIModel( @@ -49,9 +53,12 @@ void main() { setUp(() { mockUsp = MockUspClient(); mockAdminService = MockUspAdminService(); + mockAuthCoordinator = MockUspAuthCoordinator(); testTimeNotifier = _TestTimeDataNotifier(testTimeData); when(() => mockUsp.isAuthenticated).thenReturn(true); + when(() => mockAuthCoordinator.reloginWithNewPassword(any())) + .thenAnswer((_) async {}); }); ProviderContainer createContainer() { @@ -61,6 +68,7 @@ void main() { uspAdminServiceProvider.overrideWithValue(mockAdminService), uspMutationLockProvider.overrideWithValue(UspMutationLock()), timeDataProvider.overrideWith(() => testTimeNotifier), + uspAuthCoordinatorProvider.overrideWithValue(mockAuthCoordinator), ], ); return container; 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/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..d94e64ab2 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); }); @@ -55,18 +49,17 @@ void main() { test('fromJson defaults to LoginType.none for missing type', () { final state = AuthState.fromJson({}); 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)); }); }); 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 deaa00f5155565f7a9eaee3e0d5b7953dd35bc58..2d5597e589e893c319b4813b45e2d68112be6ce5 100644 GIT binary patch delta 72876 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 O&!=_o&!=6@5C1=+8pNsq delta 71645 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

vey zr#9jv)aZCc=88uAMr_}Ujrd?yhudf?D$-U_<49YeE|u~#GWbx{mb;?x7a6=QXuy>W z2y97{WMnTP3SGA_Tc$*}Cj3EtiF%*ggm>b39`R%g-dF^h@@~)*_qE_v#o(sA7T)7q za-50~EnD(H=wMTx%GfgDYsMGHAJT&yHXrzFp|ZmUbtts88K215FXD+7{AJZjv+@Q) zXrLoQq|0k`Gsy)3Tj)6mE}mArJ5sl|;{Qf!St~vWGG@Ehyfq##x8@KvL*KXNFR&VO zJksD}xw`^?5JhNtVaKuD{h$oK_6kVfHu3QdJOBZC^9}qyJkr|oPT8o01%)NSK6aUq zfWQFj6VlVJAT19wsdV1o(nT^Y=C$R|;22=-b{P8(QL8;bu|s6H=jTN3jr$cSWTm-a8nRq!86Lj*L)uybTz;U;O)KJ_4(A@@BrRHcl=A6m$+Mm3R;0AF(BIHwH~%zBtf<-=*(> zb-|n}+IHkY1aLjvk>65(hrzR9q?zgox=2VwKnM&XI!XT+2$ZVrWWt#$N;_h!?GXM> zyiU|d-~^n|W2$J;iD$B@qF*N-)KSuftc|_FX6AO{sSVH@;w?Qv4$lnM6@f<#f`se? z4Sz=)ejEF&>82p-rGJE&v1@S2y5NxGe|`L^5-W`hwxoL9p_Y zkQnSKgmg_gEFo_{MC)$o>EBo ziLTZ3Hu!iht-EqTBQPT87Ld1MvFH|FlDq*%7)(za5Y!Q7YT-v}J{v?~cixj75PQ30 zFrSMSw_-3~hwi-s$sr!dqQMbD)#jRj{YHHd-05Vax+0tlNC*>okSh{tzz$9ym{6Us8->E z$yBh`KMTs3HVzFT2)r^6LBvI`U-tq9`cnMXix0al(sVLbT&mkTtvDTk1>$t2ga8(IWdPW}{IB#Q6kHgm+jJ0YR{g3W=35$z4qC@GBE z&NxXYA_1co8$h9Pr z;wD-`%XmpGW8>buWt|<;kS!MyAXmhMC=mI2nEAfQ>kY_E6(9EIwRF@YV1ceQ4E`sQ zVqU@iDyJr6#q2)3K1j;eKD_Fqk^+@e5lKKQs0dd?ubhfdn(WnE&TOC+EvF(x#XzM& z{NHEtHB5TKG^WcVSm6rd64Wss3_3La5wJu=`*( zeJvSA%M;3df+uMZd&c(#g`XK(-j{cTS|a#Ud`$A!L@uD~4hX{I0h&rKaG2)479T#v zCxM5ze;RaswRrq#9Q+Q+SlwhX zZ7Q!RVqOM=xvEI`Q*91wQHPg#LslZ5eVM;zlpr1jXYDtO6EE}n#wAK6BV`Tb=~cxq6t_y+8zmic@o9;fp8RQ9$Q{}%68eUiryF_5ei zZgYtj`Y~1I@NhpFbYjJrqcev+aKlN z9M1bNh()_c@H-$6T{lw7O1F=Mj}zc@IO--3v}riGATN<~2-ccfBdzYYay z(kN-)XgCTwLB4ow6e!bo;@469aU9j|J{nS$O5P^iMLY@b$lEJU7xCAEdn>3_I-;&H z7Zv1-2Tr&F+FQlAvHaec$z+9wsTxaQJ|xbLMMqzX>&NjLQFK#|x@=3_HICVTFVD>kZn?e;OisO$0P)04vkOY<4 zg`<#B12&v-;~|I666?leBC`aWz}w+`&`lHgP`|oTM#Y|%PT&u+($IAiA$355=`MH* zg2MX=-og9~`qDmZoZ5EYkJvc#L#qVX0#JD8B>n>oNS{sS|6=cl%(wX_23-AN3VK)} zY8Hd992JX-u{6Jm8t?GybBTvZ%htAGkh5vpGIfYjH6y9~0;Di*_3d3sRga`Xl!}g| zzNA!QB(>wJ)FDd6M!ubusoY5Nt>KlM8B3}1qEoL*&81X%7bTP`AKGq8-56uI zBB?o)Y7t58r&MFJb9hdLBauvve4J0I^6pnss=UK(l%hqAqc#77Qh=H$dKY|TiCFe7 zFYXG?1Zx-(3(0n*>J82$nY-dl(wjtPVkW~th>^kbb#?JDm+HY5h@(?^BmSu&j=#&F z_}^_kRUDlLi@*sn^gVu8B4{2Cu_$T>H4IeatoZXi-b_)umw!vuX7z`E?!d!9YL!L#AFp3|8+Rh-w;ZMG;$H@1F zI()@lf$^h2yf6pqazSX`9NvrN9<+EhbvFuPP!V$qBNWkC^G%-3vn^Nx4C$ev zcbtR`R+pL;RNWw-doMUGx2n(T7pS69I4Efw^hNIpGUb94qgz!v#XzniiK05J(h$W! zl&AyHK6HWRb5;9bsNwd>VOG8U75o=>H<$q0Z|@yqPQ_hS87|NwN}^voBHcvV%y9Vv zTumK;w9sm+&$6|b5YtStrpTw*H-*6(6Ei4~YO%qJ03BqNzbB^x*~*vEpWlml{V&05l3E=?@23OpLn2oqWr+ z!B-XFt9mW)RlNrIs5t_kICh6Ssa^H|2(PIB3SRgKc!{FB+%9ViVnM}_qe3+PU| zYg9N*jtX3>4BFB|wlB9zxC?%*8keYsmui6HM?<>~m;muHEGsU_;1$a%UcTGiBR2_f zPr?NLu|i3IUm-6_N@s5b$l)FSSpM*=~ZOlVO0n$jXp#7?K{V6IVN-FZ~dQ6ZP)VyXeuD$|B~m61 z#JQ7muf6r+u#9o#%jk(LqlfMv`3L2q=EAE8nNDH}q*`*Z|5vLBi59B}igZoNg|Fq5 z%R`il=&q!_YG20FqH;z@E;*dr%-$y#6!0T!QDi~kP9$iP!U$RTLt2<5dj&nB0Bwr2 zzh7P;jXG+lsbwXxUgCj=@E}~Rd|9blG;wp6AH+O-AOpxC|LTby(Gx*|h1J?!Eekgb zjw&IT!}0|Mlaqp}bZ?6CmT`!rLm^;V85ua(2aa)w5AMU?3&goM$hUO-&7}x-*z`=+ z7g*dVA7@5jo-m6*z^QQfc2{|aC*?$&(t^Qd;|QoGb1V*#;(P-pjTIuYE?dKy6B+VM zMj3e<4|r8K49_k3y(LN3HE{PL;jT%zi?Rq#Rk%xh=4~H>Sk_1SV zXOKDlEMk-NMzTQElJ<7~TFa$>?VBUT=3T<`)a@b0^it z2^1ocl@aaNazqnHdxPNm%c-D3vvMqxClln>y9ScIOGrk0$Zy3|W)5_m1Edbs{=poG zTzc(zieZMpLm|v04CTU?E4P(A)OQuTfPf^~g;&yJoXDxzR=RmP>`8A$(Ru2wOq>NU zaEJhpYp@PD>_NF?8YbQWHSHg~16Al0ZYyj^x_l(k53(ZsLb3qq%%nN9 zYYD8M5xBLVMVs3s z1&W5z4f3+OpOoB_zzRHa305!yAHhnJ(gtFahUrr2NZBy`2_r_dB0siVWL$8Edtyin zNFc#s+i6TS!T#6xcQC*2a*ZF4-SyTLLvm6QVC;;Nq;%*DjDMNou0b2BZg@y zH$eH6h~Nt|=Fp|N{O+cR47+n5Kgx!P4nM+M1-F(Lr|+Nokw1xG^6ft&9%KH*{d_VK zZ|{c%aDn)9KYx_{C~iN%pZBRF0vr`KAAk-2!_esiyf=jk-~BTm!;)5e0y=+FN>-ls zJm2yTHCK!C2T^i$DDVsaki&Mc@i+boyq_J*;KVr~o+{%rW5CDBnMCq;z!2HZ-8YH{ z4)GKtqWFsJhFV>*$5ypy%h8|e$!&|V! zWC;=V4)ePa8}s^M{xy7losaPL@cE56!kg+lr;!6llpKLQe1$lFgg2|U0*(VZT`KV@ z8Fw1snv5LW2Gsr!-XQ!jNqsb7{S1H1tN4hur3Uavc_SZJ{=sYGuLHuAjA(B0^B??m z*b{sI31j#yF-ko?{}W#BS>m8fh2oELEgZ`Y1C@PjH_RRRp}NNq+Y-Ok+g^j`LNa0e zRT_kgJbxVi78rqhp5%0k=ERdQ-mDR8PjY#SCf%D0WRczGqH%#M zT4U-K%}9|@(^D|i0^6pX<|lBzd-NGzzxrOFJ=VudPFH5vrE3JFN9T~(e1?C801xLZ zY%d>+gJ=1m-XrL5J7Tj1&9`+N29Ykuy)KoIf$&Y#J1OGOnPnjvIT z1&gqJ$8C5GK*Ui0!XvuMr!`NXqC!wf`!xTb*4_g?s;c=P z-n-}A%_bX?3rQfMrO_b}dK1W{svt!L9gA6~UJW0Hgf!bM8Cn(jJ&z z#ut3^Dj}kl=DK=a-BNb*h+3}k){W&bk9fUWF1s@oe@B%7o-YR3oT-8-Bvh;~w>lWl z2%ub0iMV7a3KdU43geD);>G&77sY0;P%6xfy0oYMVM$_1Dsc^?^FN-0JN=}5V1g6rwZlu#0M8%mZV8x=-~zL z*I~lVPc^I!7Zv%cpYPy*2p23f5^4yR8M(s{NwIckS^OXE=2C3I5j)FRLp<0R(jrRi z#FxYt8L^VwEGJ@LqDPh&Uuy@vJIad!5p_~6RCN)hM0&xQ#hTBH5|3e%l&=T_dDCfq zMNy1N{iKrUl5ke>SGbeLHgzYXoXcHI{V3;$6cR0-gPo*TqD5nTK93g9VEY*{BA(92 zh_?NgNd^)s?Gj~!SveOg-9hF2SnY0~lTUcAEkoVsE9Xgdr{gfLGQ-%e)lJo-MRkgg z5#9TrX1QA{d~^`sEU8=w1q@Qochu|0$~h*8&oJd2Wz}GuwTkCTKU6SN#eQhl@Ns34 zs$HhgD&j%=)pdHlifB>p07ecNP6E@6=P0iV_T42qTt&2m?X`+k#d;0;Ru>xqN-M{TZo%(^Q4WLzx2mbJqJIUYDm`s;zQb&5F2@HuWM8a!N`uH~ zt9bEnqxYGCGLrAVbz_lrRIqR~LV+{JD={U>7k4;{@vLlycVoP$sXIGMDc?&>K8Cg)IKucvUU{C zr~FzXN&|oXKyA?n2UDFoVwmeQNH`fq-jC~u<{EDHAAedV(eB!^*-f}b_foXt`WrHi zmIB?-a_L|_5u1{E3Eht{X*)iQjjCmaT@MbelR30NB_N;38x>Z58JV*)_)&=#^+iIs z9q30T2G++&Ori<=$)~KgXjO525zwmQx-y_uecH$f8r4A5L8}%t5DA@*Gpbm@{F6WB z7-M9vC?CKq&;^!dp=uZdan&%^TQ==H8Gl)M$Vd_a=uGd&0??U};j$vVo+QfQ5^HP{ z2IE)CO%i5#-(c{VL@{bki#y5c{{<)N(-0X8Xas*wdp~T5_1F-w8|vT&)&@S2++aTB z0G}WZfIxNZDcwO1I_WKGB+^+1bwp!f*8D3t0Vp#T7oyrTIix`*2sc!JtDLqT75&gy z)UWUjt`ICJ2}?JJfYf|P(M>Sa7pOfxawDX>sC*YalHO<{o(60<+C)Tz9>6`Eao+hQ zia#^8sc2DA1zguu0ICJS*RjIAa`MqeQS^9IQ8$g_u}2eN2SHkiOpP%zO8^PQ+)x-^ zD9!^X4pfLOTHC8R4gu65JpdAj;R}`0qL%#kqVQNF7Zt}_6nWB!VGJxsBal(RavW%f zh4C4Lu&{uzaD<^jX1E;&xMYelwJ#3(uN^5-|8qt{MA|+rM zoVDkU+wo7@#*MPtfsvE`c35j_o9^$a)2$lAY3*L(kzLhGEQH5YAkL7|v7gK5h{>d* znwd{o1|(o4jgh`V1e{K``w1%7Dji{W*!{$H1j1-u3sE!eV@9Iv2G0rduwr1q&{<6$ z^F~RpgSfDf;w-Sw0 zP@FXO`PKrgtysk|d_-|*5rPc<6N!);5I1Hw1cqVJRRvJv)*`u)D#h!vR}>=ucPW(B zT6E%J;|=axm-eu|V-MT9w!UG@|6juvYL2p5MWr*w0|rIYY1Ve38h40V<$2f`Pmo5Y zUBS3EzzaU1FWQJ|P(CYT$4JawK?tqi~OM0|} zcnhB&JBT|#F;`9jGBlYwrHI<$XBCdjA}T;8ASCu$ig*BJ?@JNSgVkZ0sB|tpWirak z>JkgQ*`~On;fb6{okSG~l6n_(7Be;19B@IQ{CH1S(M4NDle?k~t7u18P#!Q++zoW= zZ?vPEs0+2-i`_&?{f|(YA_Ey1oy&maPqLYl37S@kLFRKnoSCNrDDo~r5Ms@}OH6Qm z;!X%dH6892PnB01Uv6+efxb!9{u?d3TeO3u$sc!%`p8hdyQl$#wo`X>XaarIT{P9^ z(4Ow16U0xVdT`^nQsaBX1Yo;s?-8BBl#x9}&8Y7bF0Qe9XC&s|;>A$=p1{DC({nw= z)2?i`dH5b3>?vLiW*!#C?Yi#$PcQTtHDvV`k^EfFyxyV{e}C&OKE-^z-$z`-D>sww z6;D=B78}7YNHue|SX9?z&&Bbd8_HBshii6Cn{dVDC*x=v~v~0hnVSoMaNLvMhPkgk#ef9s9$b9 zUaA0zfw~~DOmY20WfzJSaHsSW4YWdfzMmKfzRh?2L~VSo^%Kv6fi$?kNQpYY2modg z1UQeAV4l?h@0$MNaSe1y+ymfDWKr7(#68h}autyyWOd_74iSUNQPX z(I{fIE1pS8>y*HnN;L{;>w}^zrg-Q>q8c@L2(!PB-H*ZS#-4}7X4kiTws`5Ihefou z+xzvynC2>Kx1n#L@RNt7Gj1zmw*?W7+3|zNml)5Fmsb|F6$aV z2c!i}eN3c4p8eos;ySB9rS=ycZa*PZ{|O-JU(x$dfQFmn-SUKJ24O7k)h7Wfu)iBT zjXn7p^?O>>!)K)R$+bQQ@k#s49g4?KH1qFs*peZT)x$$7@V;~2=Y?*$W=>)+OD-}q zQ8Q<-mxcbAkk`!5+3RL6hoamaljJ=Y*KnpZ;IeS%67&5K3xsm%f7uxJ4gv39UX49OdbJvCl4t_0J% z!6M2~WlPpP0sa?lfb)_nDmm>~>X0hF&_1GHQw8f$b{iy`I`O8|n}b9jy6ZXdTvQfI zncatKo^jTcaX zw%G^_2k$B;iexqb+xo?a*WW>!{=9fBdM+zwWm<4p2@CU|s>VF3@PcU2Y@;t05Qif| zjqN%s@G)fC&vDB!JwmN*2q`Y(kO}nC3*r?pX@7k|fJr;nTWzQ~t2HhP2CI*cN@L1x zTswj|)>z?Y(OH!$+uQCXoLTJ_0tIJq$3zh%5*2-eo$cW54IIp2VBFy38@6~ChAu0_ zwjY2l?x5Mj#QkxrY=($#{KX6rJbkJdB3bGyz$=Xy4rXhV zk7%d2WCTu0h6RlA-4+(Kdyaq<&Xxj4{nmm_IzJ^onJXLvJ!N4b9$0k>& zSu8DoUDW?CXW6CK#aE?Fo3Gvw9l;s7^ae1+_o(8Vq7|4bz1|d$mvVWQzbOLvDW115 zXt~twEfL%0!fBjeF7vYbFe`<}N^MQB!GYN8EW(AiVZktF<`#@LBGj~V-QZl1!=nW# zs+141_bs9BhBTb2bmJ}2u-3F+kWiR2_`@P!akzR^wLtjE(Xpbkd4>mm?|U1-JD*0r z9oY6?-wtehyEH7#TpF4Nc6T1FNdu{{fsUjBUw}Q4bR1b5s8%`_!A80}T_glgR8U7? zX^%(;>vto~OvhYoq7&(&SFqP=0wUXw#1XTJ-We(4oBi?|8Y#`+`NJ}XEX5k?0b0if z0Ke9-D!-y+gY*Ko4A{vl(JmMSjC*qNX|1uxlXc1R+EK z+7zj?{;Dh-=PkGvydLXB@&mRoJ(B3kf{I1hg1 zN24*wn`q5w5f4UX@o3RGZu}p>Bo(9b2JcHeh^F?X_0_R2JC6|)Z=dz_IbYV145)9- z`uf@5vYO*WL$q}0IFS-p;Lmf7`BfGx z)nVqVyqu?CoOrhdU<4qv4@Ts!#vxPQEWpPyl&+9`2BC}938m0DysrX%5V5pO-kWd@ zWod&UF6Ui(OWszLH(tE`j|*6Gn*wG|5RKCQW*gglv}5XUvd@Aq-shXgKEJ^J3ikQ6 z0CwHvJIX%4(!*u;`IR2BXTw+5SLSN=HTC(<1+lj#&g4E5VXYZ=Ku(3?0k`{!7EJ_D zI!n7Iibt?r8%zS0UPLcW!d6^J(79O7o8GAhZzBq*wV9N(66Iyj=CgTMAgPzG2V{wOeHXBuZL2*+= z5iaj>hGJvPCg)Vq9@LXLRkV!yLS3Hn#>TSe^A;ykXIhvoYS7}TX!|bOJykTS$H^F- zP|3IyPRaXReIb^wzCc%tymd&tuHhN>Pp08!VFC^MQ0#=S`E-D`3H0Q2Q4?4^V>&Qw z30){}_H^9V@SEg}#JXkzJH^YI*Sh~?>R zrsLTw`e`=Kfo)VV2U9u2+c!sS*D&94bATNedHc>0X_|J0zWYQBtGj?LNr1F@SIK?@ zIp;d_fdIl})SI>#D3D8IwfDujScyqcFE&E~rWJL@JU3(piqZmz3uetWi>qvwIm}hG z`BQNjR95~xEWi15avmhO*TCYoS?~jftR*tTBx^pq$5QpraAwW-KKB_0!*x27kvCc~ zU(~C<0PfTz2kdR~i14j4tVMys3NyoBG#8NexkzX*ldotq`Sw+SDuON{HbAIpm{(Of z)~##}bG%ALy*?MU?o3AvC>$l5doBa+L!4AmI7*5aR}j$p1uBM(43druEp4|9X!q*R z!FtH0wqJ-TOrN1*>=7_YF*D6r6ZWSsM6Ld7m1LDL)-XvB=S)rY+=rK}I3>6w*jfIJ zU>YJLc95Uci&G)kO9$GM``y=s%G&8H5je8^=?W}Xy%OrH2aX9?s~dU2g-NsAVPyep(5i^Vs|B`)8QY~k9B ziGS@srkDF&)=_Wlq-{&Y0N14q(6i&H?o!lTNFOW}N#zt&-N^h7Tv*c1rML|J%^ST8 zAO{#|=2zlIhb)(I1xF!v2;aUlEyfdQoWlXg5YB>t;FfFx&266J?dkz$$oLBQq_Awb z{%gQnm~i`Av}((}u_KwzpB&rQXqRp;K32Ad6`H3Q;qOjTKl-5uCg-LBRI1xr+rV6U}q@Sk}uB zg{I?)gu^RDgHq(R!Z)Iw`x{378bOP>|2LwodnfdOz-4hvUh_l@E&T@7=F_2XKuxZo zh?SsG*VAJwMWuwXHme6rX$BS$Jh_S0;{*u!aHTMDq5s25fT@dQtP=M-f6!^q7~Hy! zUL|(aV37A5@H%ycoB-;{z>|f%c$xEHSo23CR$G|tYB39^Xxla7ZcwG8*N8i7s?`Yd zf)<;ol+xt0%M$+$0>*P;ji^!a1ZEIeNxZ@-+%WEsG?1W4S}PvKnKxlA2&h83v{sA> z&QWmF0lIQ*zPQ_+Bb{iE>87`5!w4}Rx6+I+NL$COGD%(6i41{G(2#YaCY@g=nwR=| zt`GDrfDbhsT@M!DPI7Gk7cY;pH;6m6&*{evVDc1E{6>-93PdE@iGjp|W3@J3FZ{n+ zo%n)?1QMJV>@z*bj4)go?4Y8JqFds88~ej+5sdxi*%+hs1)d7zm4<8A6fLi}80N^X0_eQE@Umo1MY!?aCcL&H)MS(_9`VLVQ9FvcBhypD27j}wQ z+=XBvn~lMg`+X*<2q)`jOT{B>3MH+7ZJySr3f zv3pS0mR*5$MSXj_x(Wj7D)?7*ZS>W(8+=OYuqUvt343l=S6)Owzasukzc%{Du-jhL zb@to9y4vl&U0qoLb!Gjlx|aFs`fE4p8n`#GuCMmquC9WBx(fbPUB`WO4cRAN#8y4J zPtk;8zi1VA$xq+?%{$yjPFi#x@5~4Hi;opGUdNdOsTr6dcS12X^g9vl!imOOz4g8W zL{~huW;EtI@KKl0lJCG!SVD)t6AhD=x&ZJ5wvvK9_K7&xlI&vS0>Ga2;K3`=u%%ib z5FL0!R;BC%7=?+n>wxI#D!#U!?<~g84=7;MuextVnW!M}jv~>Aj=4p(>y{$3%@9enb+t#yE$7)Y*t6 z1RdgWhYG;<2@O3a9zwOdj)B#`+I#XC7;mUx_;H*@m%axCLl>t}-=Bf`9HDhT1A$mg z2Ywcbp0&7sP>g0{4apNCq3(1nDzK<<`s2f=5K~ncG}u)_l)7?M7TKp$uM?tEo&RyG zfH^C@RiJN9h*8cPlG>jHNmNJ!PKvS4OM?1e7Okl2Dbccy&9}joFrRj&3$KnUM1Ze; zfYtgi!XkR(6qZ>LEjuMz#1`SLi+EUoP&hh}aD}s#dMNl8q&-g^e-X8+6<2vSK`A++ z7dgQen1R-$;ESLfGJY488%$Hde23u!`>e$-7=JFT0{$oDk@oW!e8B>0gp&p3OP}HvL_#bLOTiw#jb5$9S_d}qP z3&4=41c0P$D}X#_aQUtb901DNI3`{&2l4X@?~AJ(NOzyXg1$h{oDnT~ea~iZA+0=v zTdF+z`3z94$<+6(h_kP)U0|f+B@|p+XPy|XDJ&t#tsILWqRGg58znoz)G_m${0J>pyT2k{Qu8x z;8eUoM{eMxn>Qs!4rs;fFpIioa2vn?Lyvw@a-kr=7+pq_p5?nuCpYbyYsvyyM>{lS zzw8F?Q&mlF#AqMYWN)DGO`NhT?iF5h%3{zB1B5)SVHmsVvVnGnhU>B+e4{UmTC`1< zZ7Tb@G0{8?IH?p121v$gs^*f3P{1%LlpGKki~Wn|)fR&uUnuH}!1 z@4jpuel(ngAA+EXM>#EIWGTC(Yzyv2y%6XQxO-H?lGiA(-p9Mk1lpA(Vps6;ty;*&?Q6G>tqLJo<^Y3p)I|g#G{?1g5GKv?Yl`kPf>~02l_`NR#;xv zN6VFf{5>gU1mqJbt3qJ&^DD>>+M1~m(l5Q9Zw6*jJ&x@dm_>=O&wNytBkZi|;HJh$ zqR$Ii9~SmcSh^l^>~wD=`fLZ5_L&(;yq&-aF@G%WGe8$p9*Vzf=keNN`gE@2|6U~n zI!TXk!MbA0DA^C2c4Cygr$+82G$X=X%b!S|d{(7pY?!4E)No_=7vCE=`(?ENE7Bts zWwU>>1TY}cG_aDaAHIPHp`uyDAE+46$CYG+6E$hA18XX~&MJ~x>D8mvyq$YiCQ;kC zz)3hf47+hUHf`xedE?>GBls~+#$xGThWibWe~+zk#)negF{&y#@K)p zsbetZRhCt#US%-Ac2djAcsgqrRjGo9Ewky9%IM}UDy%F!!rHJM*u4q@zoqmlvLo$@ zk;!;u;sOWlraDy-^ipLNG@~kl3aEgCDpx^JHg&3opgr_zHRy}v()4P8>3eBIHGth6 zbg~*M+)I_J17Pi=hpQuS1&yqZOZj~?w>seDemYVeNs1^uRyGUXFBzl?GmnaE$(X3K zzhhK`%s=^~F0*+~%S__6mjZMj`q!4RbS(D&aa6CxNxtWMC|y2`M4!G)H|;F*Rj- z?Faf#P2BT;<7UYY&Ns5Aj7I46nz9W=)<8aRQ~|zKLuw)4A!=OYwD%P%(r!L%-DhcV@9hhM_Aifspiz6t#zew z(@_^7PojWZ*Lt#kny-4`C%4r|F5p23F~3(I1~Gj9WF3s^jdPz)(aq|~b}rw+sB$TY z$Wl@2IJLB&9dxdqEEjGk_xI&y#pPQ7Nmz)w*Nt-kXLTE2Q)YdD#NX(L`tljyu=rqc z8Kt3Hq2D(Ru#x}80az8=U7dat`GnKW$^2n~nko%t?M`;!3=X{I^eZks2?9?)JI^>i z2_R|nPfow8UmvWLeu6xLU>ivjzmyg=7VA z{!hfAv;mcx$b?Qi_>2$pk6NJc5-#j+KR|Y#e}^8NZ6d=AH92ACajx{J!+4jMuVDR z6z#z88Eyj+qgl;>)92CIW;h|%;E@9vTP_#Vu5bc$bS<@OF7FQ>r}mPD`I+8a-UZ6; zcyl0vYbd;hOtOs^aAcPj()88vIoHm|?#(E}txNkxi+{YdQ$l%-DBEvs~Dr`rCr zwGX7Z7R@(g7^UAKD-6N-GBKqH64;SJTflL!P(mf5mu(IBuM9l+*zuhU3SPp<8ZuzN z+?rtmF9>C*3I`tdRw>rKLs~l-k!BYs)olevl?9Jat@ZjluRPvAcbUKwC(-lf z{{xK@MngNw%Ge3-bp(v{TEpWznG{K=4qqksR|;bT$q_rx1wI3Ag)O)4P>>_#DlbDTTuEX|JboxgrSr&Q{02k7h! z6SzbLZkZJYsu_rarHy_wn6s1r%*h$Gy9vxW;%HJb&~2ZMM7njyDB|~r9%|?x>#HZ> z9hE@*W9U7B`tuv^QC$w|DHFPm;Tm0L@lJe{u_e~M@=c1R+3SNZfverl=Ib5}=_wxs zKC+{yVrKl>6KKh13hf1|bO$x+1>WLjdZZWlk6Y-|USO7dO+WXNCVaJf%lMEgtaJ!vTxtFjESY`?Dx;KE*J^LcG{Eho$rDi7-$HSb<{I_F3 z>_R*hz;IO42W~KmGRB2K2T`-dt<|j=W!)(wp_dO1scpZBIfHm(T2bEyZdBqNrNtQL zPrz4D4Ba^lmiNoclI82Gz9PFMock7S=1Ul613Ju6 z>Cjub6YPK1G*osL)=Rzie%Oy1bprla5>gd{HqEX z?eVG~8Rf`Pugd0)vzY5w!Q9R>pwKNg59h%>w{01YF|C_q?r*at>c1wVE89a*S`gbf zW0tyuj=mJgPr4PFt$IzC!vfp>nj-(=UzdYZuJbYqGT&ixH3GXdF4QUwi-Q$&u(xwH zZY2aU)r8;D%9~g#f4vUY%Eo8ap2hqJ%&X#6z4=_=JSbk(+UH~{W=?JJ{?7-_95#3& zef@l3juX$zwxK=%D{w7Ak3SsWVu550JC8=9F!@2_u_cctI_coEHMy zHvTEp)L=+pO-G)RF*IU`jKM;gGz5f39u*Cd&6&^`3XYG(D`6qsu0v&V^EKc|G1`Y) zJG;J~s88_}jeI*!DKNT?HCRJz!vneu3x=Yl+gVQ+P5XfM50wqVm-F%{T_qI$A}$U5 zK(g_R0h{}g7iGh6yX(HiFf}!hDOwg5;1w#0cu770Zs_wb$=-=`thxW3KWgsnm1MPC z%{>*rB%?|jaMNVh8zz~FgRxAa-cJQi9R@UDpJOcl(LS&CjN0c9JcE6H{^lvAG0)(Y zwkf~_>G}wOLVLlu2L{%v7R)mGm=5L-Qcw{NSnSa9i9w zPP`0i~`Q9iT@+rJIUak2dGfOXZ<o_p> z!4gnrF29tsl@6xMMWNr>Dxx@%$I}NR<$oX^`P)btTQ@^RSQoxO@rCbJ`@%QL@tyVb zH}3NJ>X(|1l69L*?*(;b7xD&WevrsFeX74^xGk^fCZ%0z37Pn6(QYAT)2knzn~y4Dh_ z1$3514p;~*6f3OrjPIDi*gk>%5Fp1;Ku0oU(|gNgJr3cn|2r!qeon|!S=O|bJ#RGv zu#tsHdn!}j3mbOcOgS8P91Y&V1{v=i{0?BU^HdRS94))_!96)hb2Ixgw1$7dOyUa+ z1%g55xutk{NkD}HK*`KxAup)N6c*WF%{+w=Ls{Ni$Z_X=aWr5IkmK_-YYg`8G}NFG9ykDQ>hKOEwrjpCGjNsu`Ma_K>xtXK@k%l;!~65Qa*B9b z8AHgKhL%+`^YO8)BNT8zRr9nz;3@~SFqeJkOR*6yv!!{}A24$|0+P&~_}J3xY_~6O zD!iHABNNaCZJal85{AlpUsZ$t+Eq@R>3uIt)~J}K9>*(>(D5H}C@YMF!Z*|BGZ~91 zZ|1UJrG)^yX}`tG{!o})g~cShVW(#znd+R~^h5ptyK_7J0jm3VRx?-mL(ZYu=zp$1 zVBfn4s9gFCFdE~3HCQ#Kj;|@1^zvt5y5@Q3ekPBxit?DxWqoH8)s&fpQfip__*i44 zm5vemYc9j1TmVMtYS_KU7hthrwYB_GR)bPf-!J9s#ymxy;w=%gm8RDK zAk#6?tFM-QoaK4GFepK2!&S~Vkp`}nV^i#j628$XGgek6$R}E5tJs@{ni}N;tH3i# z`Ep?eyD-Q!`jXQN>tv$yJ!vT@3|%MVou`E5S-MWf1})E7=P(d5bU${(We%CoA;_RY zI;@w^Lk{K3^|)c%K&RKscJS5P0DR{&%H4n$9!kwc!i*&qZIGF0%Oe{hO_E1n;3GhL zP@_$<6086in`8^{DY|Wv9TR=hCFuduCG57OOHQ+x4&`nFo_&D|H_1Lwwyw7s2+HsD z;AZ(uwPLn!IU&ST(LSPZxh=uSv4XvaHzOZJszSEP{?ONZVypZ>U!fVl2U&fJ*(P6) zDUnJ9Ex{pyH!HamOB!u59iQ3-tCbTp+K2B)x1~VFj3$I2_PSVB8Gjv#4a3d>Qw!&wdNu zAk3P4E0YXJt3knpMb+?!Mb*CjRyMpfG<5fELcMqImS1R?vWo>WF#&Uf%>o@LH8?UQ#oN9%O%7Xq=*ASlLg}~#;&-g+@edqz%!nIAq zh)tl}1M=w#uo{ZA(4pX_V}`MP5)WeTk5adT02^y*(m~l2FHCJX2o}nCx^xhGZ#=aq zl-=XUD{M%{x!X9xvG5OJdcs&x67H~6C~=LaIfb%yb})kWUurKEQgc}vOsSs6*ji3B5o5KChI}up)hzh{Fo^+Z zYo}&CRTcO+2y3qKOIeG*m+hhpp)-g3#8a#~$Kbt%k|cwU(50GsLu&Q|*69K2`-5y( z8IQc;_65u2E=<7HQNTtb%$Y2@pGUbr$S062{1Dh*2dLj6$*;A(bx78z!{~<(x02BJ zG-WqsSHWSfX0s;zBrA3{6qL6el59FV^heCzTrz)@sgJH!J&XsM2c=0azTzHA}#Avd^U&UqLZ8HPyljDdeym3jF7d z!#MB&4ZzG|XkZJj<{rkJuApm&<>!EIpB<6!mx=<@Our;X_JFSSakIMRyg-nQqn8)~IWBXfa-%%V$7p1k|qK^So zPo&Pru+8!)^B9PQU+L^IIRNOv-9O3aG`!UAJuaz?1~A|tHnDkbB&7bLZ`J^&SAItB zk7@7EvQ~7qg~r2jw-1dk2Kov%iE%>KiC%pB*u_??O#+n7JA<-L;NtfH<(-gG5ct`Q zk|2e6_Jph(y*gN_9yn82`_BxewsC2K%BsroOF4V~=u z{3=uqq_vOu0dC{Tz5&O8%d|!h$`Wh5fcX3&F3bDc%lFru_)#j0!oCN%keP~YDw&y<;*P{Fv%ZK-u&z4 zfG~4xQJECq-yBfE%wXg9lj!lYveK>lf9zRVwL)o)9&_{+&|Q=0>$CD+k6%*-wn`$- z$;S2ma(NnIr6nrcBm#_Z>m4mD=Jzn-0cge;df}XG;Jo6ZA?JX_|80N22%;r~<$@F)FRAV94I}a9~UqhnLc`V=Q^zwP^)?Z*vMV-x-yuPO?uk}bU5|^EqYoIeW z><`d)KTyseU~yiey?@9z;p=q)WZvI2{Q`KHf70;_kXrkbs+Azg&(x~~dvhwiS|V#j z9^;k5(&5>_C#)0g0xc<#HDl8G4jvNP2%V33McOZ82c0Xy6n;%rF3P^B{>6(pJC4%_ z7g5Dd+Hp~)xUimqQc(Fn0SZ2++JDN05Tefd3tZL3bnZ`!WB!+X!56sH7x=?pU~QbG zW|!nW+WR!>5&-UQT5?Gyx8O%66R}hP4EU6`4{Js?#z}>Jf;9r&Ax{$0uzZ;HxFkhi zMoZ^Wr^}!s3u(+{aAvMumM!!}oRn5wl8K4tepZ0x@?&{iainnZp;fF(aN6yZpnMYBcJ3(bZ`mSk0`COiGioD4slj1fX1>S;m{W!64ja2rW&M}4&XZ^- zD|5O*k{uVWV!(#Go`Jyy_->sS_l(mgk1Y-h#Ef^7tg zO;rFqDw#XA4ab;L+ffs-ALJ4emufwD zJT=j@B*#X#Db#S8r_ioNJQmAsmKiKOP#;ZCbp9>*8>_ovzyq6C(@pH*Lz>Bd3dUW`Guu`AZ(XVskbWk zi_4RMH6wU^ET=wF|BrJ%-uje!8*KX%5_ni41?hS`K6Q0{)LoM$I_v4;VfL!EpwyN| z-8Y${@g#6_STlD4e^ZCv_YxPN7zi;;^N8qEsQ zQ-ZJ}P{CF@Vzw_Ym>={ZIrJd6?Z!+gi+VMLYR%UZm6eT;c7Oo8N5HiFZ3vBnD?&Ex&d z!nU6Vt1(D&Z1pdgOK6B%Fj&JEvDSHvk{pNp@!T{O9~T&3y5P#@f6O*Nfjj0nx*n_t z({CYq4Bphxo6@ony&?d|ju1VL1|bwe*vdTR><}!o`}hy11+E+)qF19IgY{0-zZvo+ z1?$NqJ$iL|BUrB(%F_hei}Ab_)XAfNpiT20@aWC8uyarthOoa`z|4xV6jM%bAH>EX z?$@Az5u|qro)5Ugko`07ymER~jcL6wy&gVi!t{#48$l4OSSlB;_rYoLXt*8~=UY9M zQLTMOFg#wus{%zWq3m!xDsO9-LOEb{xE_0tZB=B6?(x)i06WEhJg(E6VbbArYK#gQ z1(t;pHp8+TBVR?0Jj4u?#;P&VibYK)sji_{hvkbq4ZTCk1qijlqd~6|}tS zH+qug=G~=8<8=H}&u%gBJwRs-{mw829~tg~k~99GF6H$Ue!{uD-btAz2MD)!B1*7q zuG=a&Y{Zv|Xb^DNk&4{67L9)&8@~tpZ4z?=ukIPBAbe?x{XWywD5(xg0*ww%hgBHe zSwXL!wuCoH)o0@w4T5$y8+l|y;&ypWF;u0cEe~jG+0yJrq7Aig%JZRZH`&)0w7~Am zL!2(rXJ6m=*k8EM_`E$3MD7Scnz>XLAL|`}(^jF>H$u0KgU<-Cx6DJPVdYx<5-g>E zU>tn8RYg&R-mv_8d;md%T7B!Vxt-`py}I+7i_9QBg1SfQwd*dlPZQt<4rdDg$Ez+Y zH~;}>`1YNPhDQSAFQVy@fL*-5jf=E8Qm>TuK9DvA2wLAXUK;~u%<70eUOa$UAecZU zn*`hBY9TA=jb*~*Hd+vBYQs(Ei>aPoHo#Ur}pzjv%poUvTCCaF%cMMSs zCX@ugxx1p?&78uPY$`$i*yE|h6=*M_-rv$eY`}_#mEXdum|?&NQ!thSUOrdo6W2lg zE9o_`INq$J_e$f8J=;JMCpMJwNV4;`E0r@G><|-vEy+5yTm8FY;(s99H!EbSO@*T=9a- z6Nyp7H1_62r;egr=wrp}5zZUJ`tEgE-vxH~KHERr{(fnPZ_%xEIaW9x6^o14BemJ; z)BY=*VRccJI{Tblm2F;ut2NdtVG~86yvOT=WxxVX$qUpd0SEL2TA!e^F8k>Oy+NIc z*jBtBc;S~GPFMhH5dszfNCJu3Ln(=RG#E<{B6)gV*q0;mFhd#i@yd8yF^Sm1$xc%lC3@GnIEgWz;AYdy8TnPx+#sPl^1VHZ}4}1j#Z0CS$0RdMz;5q{CL0jYaw7bS3Hv*FE z;DGdi#$V@vkuVkQA113eZH|vzp3tbXlAm`TE|*~%lh_@Y-5O6j>(^x+qF_!thxHteM zASjba0wbxdTVA8eAit6865eQE%fAOlWh?qfT^*p zMj6mgw##JQ(AfR3EzuvK*p_;A*y`|imrWR)_1@D`e@FwI_@b5GxE6OU0(|N@xaj{7 zcHr<;aBY~xr05;sC;WZK5{ve%E_fMIldTI0q3tnmD=i!Tzg90kvlnu-*S}0_&C_pT$L5I zonwG!`>UDbD=c!yt(oiwC__}GJz!criypZ{znkW^)jP{QF1w}WZ?;&4da3#ydW{%# zjnQJ}RxpRsW(7EmPwBxs^zq%1U&#zH`v;hqa!cTNa4T!EZZtR`qK@C~woo@aLo~wa zys39~A3uW?8*6+w-FMJ?Wi|{0K@8zdbgm@ zm`!NL(cu*RafGFqdR@u+X`QK84Ld$DAM>ina1^4#JVo#N!)OhXhge0h_%JnTr%&SS zLG)HTy$#)Hr-z`>koI~v4%MyDN$pkWbc7=ESbLpcw+-*0K6iA`ABAsb2YoQ-aM8#V zJ+|(RvHmLU7aA_G?i@O7sXLpaSgf`ty4EK}74tSeoL^Jzhs_ZH9&Y7LrCIMF7*s zDrJ^#FO%w62W6MKT~{d2VeB1xWsiC)!CEyG*IBPv4U3LfgP+B010y35VHfDW&Y&5g z7v5QK+osHNV`3zrF%Y&?lB`?-pGAS&gwAvZ*}6c|4Ok|nT3z%eP-VcIBr)_-7rjc9 zrBlu5Ik$viEN~~9(?w5cl@AzY9Cd=`3%u>a4p5>-4&xq=Cx}}QqzDbya3c+ZO^1!P zt6lV;CEoM^q)*10v19qYm|(6*B|gvI81~x3-L;)?H5v`^(yIR=+an_wIU?9=;|_#r2XI?{ATpy=;Ws zs(Cwm4Zrs{_Ojt|D|QNQqOhIk()~U3CO~(s&}kVs3NZpSwTJ#NZ&L8}8O7bm{C!|n z7z=JX8tjCf>I(Phbz8z*Dx@0lG$#g-H<*>cGNfu1dXC>j@o>$hGn7Lw-J?Gkc2n4H zsM+fcTiG^?th`4zW7n`Lb;~>U4-~~ur0}Sz)5rmr$>aA4nLYKGFbGV7AdtpS5T}G) z!a+U3)?G;BdV+hmgFfr2*B?*_RXet-&xjYwD0ZXKSaQ}^Nmtq(8LI(Xv2X>aN&-3t zXQ06{nW!ROLtK{Mim3+i=LWs>!$ArInK>nY@tOwqDLva0JS)4Y@r;kK zHB_q-T-k6XJ21p7)H5yEj&*P;9W^!`$IuQ-(=r?f-$s9r{^TB6H9GBYV2{*lpC>)- zn7>9J^wRIc72JtldM)hIa=rCCaFX8L8$A6>qo6AU?)Y5qqTafug~CiBOYZP6&!ts; z^wyr!qp+<1#2;hV)O+=qlmb4d&?*d!{d(_v5NZh!Zx{m%o4s}TPXm5)+sd!74wwLb z;9kAqqgxm#WV+R@yaBF@Q^^Ej5t=!?HQCTN9uLW4cOm{LGuze{2H4PnYfH;FA;=Sg zVZx@t%dvjvMmlk?em9sbjqd{w_gi}CK2VF7X~cc#X+GuMrw8bY#N4lUFO?#H4!%ez zrj#ltl*6`Qw)xGv38(JYOP_0x>Wj*hmOwzcNHHO4WXPx2hXh0fO~`*;a#X z4#ubaLTUZ<00oF!MfQ*M!|f3Z(83ll%wqiAPcKJ3`|Fh%H*8 z#Gm~El~+>L|A2M=6Seq{-d+31JL*4rL$*)7_yHVF2k7tvdexXOKm#*ZNy09hbr**| zGj>K119UdK(s_X1!nV`bdNwB1vQC>HV1YFMzuC@1(I7n8IxX!Tpx3He#$28>bE;vx zcsz4|3ZCYTd{CcQzEbj&t(!Dy+C2H`7OkFY-MnRsMngsnZ!}^UHGU5F1;(p-b9(4y z-SAd=Ue746&G626TYuNtC)4R*nJ340F@_@?-Fqm857L}}IO&lL+;u!kBQo^FXC6WByHWA5 z;g1g=F>J`QBVHIb;)!8TzBKrWryD&tWZ>Wz5LXgvILhJcK-%W`3?K6B(=R;z>d?V2 zJw5Ev!7n`iW_}z=2&%Y7{_M&oQ^r}YcupvAUzL>k| z@iF=^S5^a{`}67hF?wg`xgv@itMA8^+r_c^8N>0R`{2(HRr$=AZXD@*eZ>iS5nLWx z^FUC2Z~Jlj7kd1vU-BIu9W^*=JItNznG(r9p4r;?Eu-Kpy{+?HGjG={y@{xsBOR(Y zZ4q||e(msk%noZ0cL)4Z@GGW|C+iV)3+$-Q2Gf>f{^Zby*?O$@7_GqPiF^z? zkNaT!Qt`_Qw*1e+{TzOIw*Ps!c~Fx>toR{t55=#*j(-vEm+;HC{lnlMj$cZ!x6Ks2 zNQRVL!>DQC(~YHS(?J35^gc3OAFKgm+d4y^6IHW?;i!*aOZ>Xx*Bih8P})qKK+|a6 zO#PwIOMvU1i!L7acgPs0H}u}|k$&FkywsT*<>+l%HS1zHCL=N%zlr#*d-84^MuW8xdvGetYLL21u zF3?vydw%d4S`c=p=Uxx$b`0=5;Ths_nA@we!yM)T z%2=egO_;lYOC1-Edf6qr$tMN!sn9AwntokC#f$WMPcF-={MdNA!v#K}>hogL;9c8Bya5|Gm2KyDMyBj)LN zl!I30fu0&mVzJ(5;HgDO=KQRJ!<>`HE-zfil&dv5oxYgErlUC@DAxcqXD7RiO%)uD zJ@_5K&*3jtePqiQY8r_)?^k z%_BQULJ4mf++n@lcSjCjNQHkqII}p^`4r-FmFr1#W2rUyeksUQzv{o(4a;gneLoJIrw_*u}-nWmia) z-NK(WFh5n?P^3SnT%o$docIk=$@Hk|Xy6idJF^gUP?)Tqo@Q zM_1^}oyEH-<{MnXZ~Yd@@^LM(T~8*X*j0l<@?H7#(n@_{%(T6TlPjD6t!BP*1OCEY zNUc}lk?Q0DZG-Hs zkiC;icBlwp?%|nZv=R6@pgskEBpuzRM`BG~`hjCBB4rDedKqP`*7JgL4kP%GM&)<8 zA}M{VZqTHyuH|6FA3BPN20TJ>)PAiV2E6v3wR#mX^QU!WY&&36h)pSsBf#>0C3<^DVW;7aNQ&%$y(&2A%^-S)qxDty`nP7|aW zQlQ+y%3aKEL+|Nl0}**44jO>^3v6a2l>CZU+>+}_xx&ahm$<@;M+-vN% z;;&qX+qoLY=;#}8g{(w7R@m)W${~6>YC&7Jvd10onbjo$KZp5!`g&yaB4frVxVX6s z*%9^?N?3~DV!M=|IP?XM&&-5Nb`7l$ST;?$tu`!YxBGq!)q`rdPP_wu$Rd=X{vqe6 z(Fm#hB|;Y1q4S-ZW4>}*Wvt{BzP9Gl#~ZOrPK-rZ*gT|~i{Bi(Ju}`#C^u)-I5_NK z*vD=TygH7~Y}Bhqm5fI~=s>iGe^wni6u(KY6qPlJqh3ZZ|E#E0bPu9ZXTFapcj;2h z`T+h&wPMi~-?tFi9OfF1bI#}p@Ty!N!gWQt2(F2fId%$MbChc`TnCiveYi>{)3=-S zC(^EGbE-)QnKy-96X5zrx!!~8TjhEeu4BqI2Ck`7Io&&O<+01&P1C1wXok~au4ESv zL?Js2-%{mXT0NclVQDs)_s-j_r@$`J`OSLg0qHXkrQ0O+0;;X3THvQRpd2^7??)ygNwpzBI-Ku8GUoywLbDJJ3w3*)1 zJN2O&z7Kz^hf3T?rta0-R+#n`mt&=!&+Z1a_^hkJ!vGyB=5ygl!J1;XyE~2!!x033 z?$_+kil3={fFIJbUuG-!Trb?A1#s`jZ?6i==P)S}IPHG#dTRWKD=Ml)B~0W%{Hv)V z=ThoEy?#{jaxSBt!-{%bMP<_$`#?|>ub`9rKz}c%iu?82&U4>Thy8lR*tsi_!|hH6 zc8wn(KKtd5^=6s?LaWx(qN}cIq5zJ+uDae5#jDrTE7x4@>d#-p$-3tCh{kSWx7&?L z#?ELjK#q0;nd`WSYbx0^cHd8Zf`gK2_5mR3 z>9qNP-XrMJdW0Ve#%mjTD@r=3yS)z_)MrU&@lNmMAM`Cw-~;Op>k&ZrzB{ZJr_pyv zuH~ai?2zRhYjFe>+V0hGXB|MeIy89btl3+o!q~0(*I^-t+3WWly93tb%!BYNP|taM zKXm(`M1=pS%_{U|v#QCbM~n4X=ea^kFV?%J6&4}dt|9yTQg=SPL(4l>szbEAV+C4$ zNyV3Uc;U|Zfs5sxrq;f>h5fv1i&cc?_)2IFybNcS_!C;YcmAQ$N(v6uXOC6FDUJvz zG5bgMbDWiND-AuWSE`tK7{Q^MV=>BKB~5dqrz7`%T#Ru6X2x5vO{8cR2h0iWDSu|MgFO(y+} zMCE|Pjmyv+?+Pb=0Gb@=c41if2b6^U00->OrQtv6Evuh8fgs};f*8*GemLk)%sIk8wI@GtQd@HB-aecff`E@-NbPS4$$@ Date: Tue, 30 Jun 2026 16:24:23 +0800 Subject: [PATCH 18/56] fix(auth): address review feedback for session token persistence - dashboard_orchestrator: use isRecovering: true to prevent double navigation (restoreSession's onForceLogout + NotAuthenticatedError) - usp_token_storage_web: add logging for storage failures to aid debugging in private browsing or quota-exceeded scenarios Co-Authored-By: Claude Opus 4.5 --- lib/core/usp/providers/usp_token_storage_web.dart | 8 ++++++-- .../dashboard/orchestrator/dashboard_orchestrator.dart | 6 ++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/lib/core/usp/providers/usp_token_storage_web.dart b/lib/core/usp/providers/usp_token_storage_web.dart index b04d80851..138339402 100644 --- a/lib/core/usp/providers/usp_token_storage_web.dart +++ b/lib/core/usp/providers/usp_token_storage_web.dart @@ -2,6 +2,8 @@ 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. @@ -16,7 +18,8 @@ class _WebTokenStorage implements UspTokenStorage { try { _sessionStorage?.setItem(_key, token); } catch (e) { - // sessionStorage may be unavailable (private browsing, etc.) + // sessionStorage may be unavailable (private browsing, quota exceeded, etc.) + logger.w('[UspTokenStorage]: save failed: $e'); } } @@ -25,6 +28,7 @@ class _WebTokenStorage implements UspTokenStorage { try { return _sessionStorage?.getItem(_key); } catch (e) { + logger.w('[UspTokenStorage]: load failed: $e'); return null; } } @@ -34,7 +38,7 @@ class _WebTokenStorage implements UspTokenStorage { try { _sessionStorage?.removeItem(_key); } catch (e) { - // Ignore errors + logger.w('[UspTokenStorage]: clear failed: $e'); } } diff --git a/lib/page/dashboard/orchestrator/dashboard_orchestrator.dart b/lib/page/dashboard/orchestrator/dashboard_orchestrator.dart index 4d174dfd0..016c62105 100644 --- a/lib/page/dashboard/orchestrator/dashboard_orchestrator.dart +++ b/lib/page/dashboard/orchestrator/dashboard_orchestrator.dart @@ -132,9 +132,11 @@ class DashboardOrchestrator extends AsyncNotifier { final loginType = ref.read(authProvider).value?.loginType; final isRemoteAssistance = loginType == LoginType.remote; - // 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(); } From 76df6bd9cc3141aeba80d03f2239cfc72c9b8b0b Mon Sep 17 00:00:00 2001 From: Austin Chang Date: Tue, 30 Jun 2026 16:37:00 +0800 Subject: [PATCH 19/56] style: format dashboard_orchestrator.dart --- lib/page/dashboard/orchestrator/dashboard_orchestrator.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/page/dashboard/orchestrator/dashboard_orchestrator.dart b/lib/page/dashboard/orchestrator/dashboard_orchestrator.dart index 016c62105..25b824373 100644 --- a/lib/page/dashboard/orchestrator/dashboard_orchestrator.dart +++ b/lib/page/dashboard/orchestrator/dashboard_orchestrator.dart @@ -136,7 +136,9 @@ class DashboardOrchestrator extends AsyncNotifier { // 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(isRecovering: true); + await ref + .read(uspAuthCoordinatorProvider) + .restoreSession(isRecovering: true); if (!usp.isAuthenticated) { throw const NotAuthenticatedError(); } From c772492a86051072ece0f76e5feb212ef4737a2c Mon Sep 17 00:00:00 2001 From: Austin Chang Date: Fri, 3 Jul 2026 12:07:43 +0800 Subject: [PATCH 20/56] fix(auth): handle relogin failure after password change (#1013) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Hank's review feedback: - #2: Separate error handling for updatePassword and reloginWithNewPassword. If password change succeeds but relogin fails, trigger logout instead of reporting "update failed" — the password IS changed, user just needs to re-enter it. - #3: Add comment in auth_provider.init() explaining the init order dependency with onForceLogout callback (currently safe but relies on sseManagerProvider not being watched yet). - #4: Add test for relogin failure scenario to verify logout is triggered. Co-Authored-By: Claude Opus 4.5 --- .../admin/providers/usp_admin_notifier.dart | 18 +++++-- lib/providers/auth/auth_provider.dart | 7 ++- .../providers/usp_admin_notifier_test.dart | 53 ++++++++++++++++++- 3 files changed, 71 insertions(+), 7 deletions(-) diff --git a/lib/page/admin/providers/usp_admin_notifier.dart b/lib/page/admin/providers/usp_admin_notifier.dart index e85b460a1..a1869e56c 100644 --- a/lib/page/admin/providers/usp_admin_notifier.dart +++ b/lib/page/admin/providers/usp_admin_notifier.dart @@ -6,6 +6,7 @@ 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( @@ -44,15 +45,22 @@ class UspAdminNotifier extends AutoDisposeAsyncNotifier { newPassword: newPassword, ); }); + } on ServiceError catch (e) { + 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. + // 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); - } on ServiceError catch (e) { - logger.e('[USP][Admin]: Password update failed', error: e); - rethrow; + } catch (e) { + logger.w('[USP][Admin]: Relogin failed after password change, ' + 'triggering logout: $e'); + await ref.read(authProvider.notifier).logout(); } } diff --git a/lib/providers/auth/auth_provider.dart b/lib/providers/auth/auth_provider.dart index 1f2d0d809..7dbcbe310 100644 --- a/lib/providers/auth/auth_provider.dart +++ b/lib/providers/auth/auth_provider.dart @@ -44,7 +44,12 @@ class AuthNotifier extends AsyncNotifier { // synchronous state change would trigger provider notifications that // cause a !_dirty assertion in ProviderScope. state = await AsyncValue.guard(() async { - // Try to restore USP session from stored token (sessionStorage) + // 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(); diff --git a/test/page/admin/providers/usp_admin_notifier_test.dart b/test/page/admin/providers/usp_admin_notifier_test.dart index 612b3ad65..45d343cc5 100644 --- a/test/page/admin/providers/usp_admin_notifier_test.dart +++ b/test/page/admin/providers/usp_admin_notifier_test.dart @@ -11,6 +11,7 @@ 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 {} @@ -18,6 +19,20 @@ 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; @@ -31,6 +46,7 @@ void main() { late MockUspClient mockUsp; late MockUspAdminService mockAdminService; late MockUspAuthCoordinator mockAuthCoordinator; + late MockAuthNotifier mockAuthNotifier; late _TestTimeDataNotifier testTimeNotifier; const testAdmin = AdminUserUIModel( @@ -54,6 +70,7 @@ void main() { mockUsp = MockUspClient(); mockAdminService = MockUspAdminService(); mockAuthCoordinator = MockUspAuthCoordinator(); + mockAuthNotifier = MockAuthNotifier(); testTimeNotifier = _TestTimeDataNotifier(testTimeData); when(() => mockUsp.isAuthenticated).thenReturn(true); @@ -69,6 +86,7 @@ void main() { uspMutationLockProvider.overrideWithValue(UspMutationLock()), timeDataProvider.overrideWith(() => testTimeNotifier), uspAuthCoordinatorProvider.overrideWithValue(mockAuthCoordinator), + authProvider.overrideWith(() => mockAuthNotifier), ], ); return container; @@ -182,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( @@ -202,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); From 93e76caf09c385d407c31f67cfbedff4138b2cb2 Mon Sep 17 00:00:00 2001 From: Austin Chang Date: Mon, 6 Jul 2026 10:34:37 +0800 Subject: [PATCH 21/56] chore: bump version to 2.6.0 Co-Authored-By: Claude Opus 4.5 --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 2696a6513..df3534413 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" From 50d12cb787f1508583107d1ad0c455a370c816ac Mon Sep 17 00:00:00 2001 From: Hank Yu Date: Fri, 3 Jul 2026 09:53:31 +0800 Subject: [PATCH 22/56] fix(wifi): unify guest WiFi detection on canonical alias rule Guest WiFi detection was implemented three different ways across the app, which disagreed with each other and caused the guest card to be misclassified as main (wrong title, hidden password/security fields). - Add shared helper `isGuestSsid` in _shared/utils/wifi_guest_detection.dart as the single source of truth: an SSID is guest when its TR-181 Alias ends with `-guest`. - Converge all three call sites on the helper: - usp_wifi_settings_service: replace inline alias check - usp_wifi_data_service: drop per-radio instance-ordering + dead _ssidInstanceIndex helper - pnp_service: drop radio-occupancy heuristic - wifi_network_card: allow guest networks to edit security mode too, consistent with the Quick Setup card. - Tests: give shared WiFi test data proper `-guest` aliases; add guest detection assertions to data service and pnp service tests. --- .../_shared/utils/wifi_guest_detection.dart | 15 +++ .../instant_setup/services/pnp_service.dart | 15 +-- .../services/usp_wifi_data_service.dart | 35 +---- .../services/usp_wifi_settings_service.dart | 5 +- .../views/components/wifi_network_card.dart | 16 ++- .../test_data/wifi_settings_test_data.dart | 16 ++- .../services/pnp_service_test.dart | 125 ++++++++++++++++++ .../services/usp_wifi_data_service_test.dart | 6 + 8 files changed, 186 insertions(+), 47 deletions(-) create mode 100644 lib/page/_shared/utils/wifi_guest_detection.dart 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/instant_setup/services/pnp_service.dart b/lib/page/instant_setup/services/pnp_service.dart index ac750b09a..6f5d8c668 100644 --- a/lib/page/instant_setup/services/pnp_service.dart +++ b/lib/page/instant_setup/services/pnp_service.dart @@ -13,6 +13,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 +108,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,18 +148,15 @@ 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); } } 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 61698d917..b01e4e2c9 100644 --- a/lib/page/wifi_settings/services/usp_wifi_data_service.dart +++ b/lib/page/wifi_settings/services/usp_wifi_data_service.dart @@ -10,6 +10,7 @@ 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/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'; @@ -160,28 +161,12 @@ class UspWifiDataService { 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.d('[USP][WiFi] Total guest SSID paths: ${guestSsidPaths.length}'); // Group APs by radio: AP.ssidReference → SSID.lowerLayers → Radio @@ -225,12 +210,6 @@ class UspWifiDataService { }).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 // --------------------------------------------------------------------------- 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..b351bb8b6 100644 --- a/lib/page/wifi_settings/services/usp_wifi_settings_service.dart +++ b/lib/page/wifi_settings/services/usp_wifi_settings_service.dart @@ -12,6 +12,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)!), @@ -74,8 +75,8 @@ 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'] 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..18119fd0d 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) 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/instant_setup/services/pnp_service_test.dart b/test/page/instant_setup/services/pnp_service_test.dart index 7208e13d5..7234d0540 100644 --- a/test/page/instant_setup/services/pnp_service_test.dart +++ b/test/page/instant_setup/services/pnp_service_test.dart @@ -378,4 +378,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/wifi_settings/services/usp_wifi_data_service_test.dart b/test/page/wifi_settings/services/usp_wifi_data_service_test.dart index 651bc6b4f..33d19866e 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 @@ -45,16 +45,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. @@ -200,6 +203,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]; From 6108b1ff3f01b54ebc3c3c9170ef70c79a3fb053 Mon Sep 17 00:00:00 2001 From: Hank Yu Date: Fri, 3 Jul 2026 11:00:24 +0800 Subject: [PATCH 23/56] fix(wifi): warn when no SSID matches the -guest alias rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR review: alias-based guest detection fails silently when firmware omits or doesn't follow the `-guest` alias convention (all SSIDs fall back to main). Emit a warning log — including the observed aliases — when multiple SSIDs exist but none match, so the failure is diagnosable on-device. Detection strategy is unchanged. --- lib/page/instant_setup/services/pnp_service.dart | 9 +++++++++ .../wifi_settings/services/usp_wifi_data_service.dart | 8 ++++++++ 2 files changed, 17 insertions(+) diff --git a/lib/page/instant_setup/services/pnp_service.dart b/lib/page/instant_setup/services/pnp_service.dart index 6f5d8c668..580679cb9 100644 --- a/lib/page/instant_setup/services/pnp_service.dart +++ b/lib/page/instant_setup/services/pnp_service.dart @@ -161,6 +161,15 @@ class PnpService { } } + // 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'); 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 b01e4e2c9..7bdf78a75 100644 --- a/lib/page/wifi_settings/services/usp_wifi_data_service.dart +++ b/lib/page/wifi_settings/services/usp_wifi_data_service.dart @@ -168,6 +168,14 @@ class UspWifiDataService { if (isGuestSsid(ssid)) _ensureTrailingDot(ssid.instancePath), }; logger.d('[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()}'); + } // Group APs by radio: AP.ssidReference → SSID.lowerLayers → Radio final apsByRadioPath = From 9978d4eaa59ecb107a48f7ccaa4f2b361bcbdd95 Mon Sep 17 00:00:00 2001 From: Hank Yu Date: Tue, 7 Jul 2026 14:53:48 +0800 Subject: [PATCH 24/56] fix(wifi): write both SSID.Enable and AccessPoint.Enable for network toggle (#971, #972) Problem: - WiFi Settings page only wrote SSID.Enable when toggling networks - Dashboard WiFi Status card wrote Radio.Enable (different layer) - This caused inconsistency between Dashboard and Settings (#971) - SSID.Enable alone did not stop AP broadcasting (#972) Solution: - Write both SSID.Enable and AccessPoint.Enable together for all network enable/disable operations - Remove radio-level toggle from Dashboard WiFi Status card (now read-only status display) - Dashboard WiFi Networks card uses toggleSsidsByName (per-SSID-name) - WiFi Settings uses saveQuickSetup/saveAdvanced (per-network) - PnP Guest WiFi uses same dual-layer write pattern Changes: - codegen: Add writable flag to AccessPoint.Enable in YAML - WifiAccessPointUIModel: Add ssidInstancePath, accessPointInstancePath - usp_wifi_settings_service: All save/toggle methods write both layers - usp_wifi_status_card: Remove AP row toggle, keep as status display - pnp_service: Add _throwIfNotSuccess checks, write AP.Enable for guest - Remove dead code: toggleNetwork (replaced by toggleSsidsByName) Tested: Firmware correctly sets Status=Down/Disabled when both layers are written, and WiFi scanner confirms SSID disappears. --- lib/generated/wi_fi_access_points.g.dart | 5 + .../_shared/models/wifi_radio_ui_model.dart | 21 ++- .../instant_setup/services/pnp_service.dart | 81 +++++++++- .../cards/usp_wifi_status_card.dart | 14 -- .../providers/usp_wifi_settings_provider.dart | 16 +- .../services/usp_wifi_data_service.dart | 6 +- .../services/usp_wifi_settings_service.dart | 153 ++++++++++-------- .../usp_wifi_settings_service_test.dart | 128 ++++++++++++--- 8 files changed, 297 insertions(+), 127 deletions(-) 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/page/_shared/models/wifi_radio_ui_model.dart b/lib/page/_shared/models/wifi_radio_ui_model.dart index c57976ac6..94089d1f1 100644 --- a/lib/page/_shared/models/wifi_radio_ui_model.dart +++ b/lib/page/_shared/models/wifi_radio_ui_model.dart @@ -80,15 +80,32 @@ class WifiAccessPointUIModel extends Equatable { 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]; + List get props => [ + enable, + ssidName, + securityMode, + encryptionMode, + isGuest, + accessPointInstancePath, + ssidInstancePath, + ]; } diff --git a/lib/page/instant_setup/services/pnp_service.dart b/lib/page/instant_setup/services/pnp_service.dart index 580679cb9..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'; @@ -305,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) { @@ -315,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'); } } @@ -340,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'); } } @@ -357,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 @@ -368,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'); } } @@ -376,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 @@ -385,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( @@ -401,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'); } } @@ -530,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/wifi_settings/cards/usp_wifi_status_card.dart b/lib/page/wifi_settings/cards/usp_wifi_status_card.dart index 4da701920..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(), 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..e40a5f174 100644 --- a/lib/page/wifi_settings/providers/usp_wifi_settings_provider.dart +++ b/lib/page/wifi_settings/providers/usp_wifi_settings_provider.dart @@ -325,19 +325,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, { @@ -364,10 +351,11 @@ class UspWifiSettingsNotifier extends AutoDisposeNotifier Future toggleSsidsByName(String ssidName, bool enable) async { final wifiData = await ref.read(wifiDataProvider.future); final ssids = wifiData.codegenContext.raw.ssids; + final accessPoints = wifiData.codegenContext.raw.accessPoints; try { final count = await ref.read(uspMutationLockProvider).withLock(() async { - return _svc.toggleSsidsByName(ssids, ssidName, enable); + return _svc.toggleSsidsByName(ssids, accessPoints, ssidName, enable); }); if (count == 0) { logger.w('[USP][WiFi]: No SSIDs found matching the requested name'); 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 7bdf78a75..04fe76713 100644 --- a/lib/page/wifi_settings/services/usp_wifi_data_service.dart +++ b/lib/page/wifi_settings/services/usp_wifi_data_service.dart @@ -193,13 +193,17 @@ class UspWifiDataService { final apModels = radioAps.map((a) { final isGuest = guestSsidPaths.contains(_ensureTrailingDot(a.ssid.instancePath)); - // Use SSID.enable as the canonical enabled state (matches toggle mutation) + // 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( 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 b351bb8b6..bfe79f5af 100644 --- a/lib/page/wifi_settings/services/usp_wifi_settings_service.dart +++ b/lib/page/wifi_settings/services/usp_wifi_settings_service.dart @@ -274,13 +274,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 = { @@ -289,6 +292,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( @@ -300,11 +308,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, ) ], ); @@ -385,23 +395,37 @@ 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)) { final result = await WiFiAccessPoints.update( _usp, [ WiFiAccessPointUpdate( instancePath: ap, - keyPassphrase: - curr.keyPassphrase.isNotEmpty ? curr.keyPassphrase : null, + enable: enabledChanged ? curr.enabled : null, + keyPassphrase: securityChanged && curr.keyPassphrase.isNotEmpty + ? curr.keyPassphrase + : null, securityModeEnabled: - curr.securityMode.isNotEmpty ? curr.securityMode : null, - ssidAdvertisementEnabled: curr.ssidAdvertisementEnabled, + securityChanged && curr.securityMode.isNotEmpty + ? curr.securityMode + : null, + ssidAdvertisementEnabled: + broadcastChanged ? curr.ssidAdvertisementEnabled : null, ) ], ); @@ -477,35 +501,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, { @@ -546,49 +541,79 @@ 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: 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..6da8e59ef 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 @@ -774,10 +774,10 @@ void main() { }); // ------------------------------------------------------------------------- - // toggleRadio + // toggleSsidsByName — writes SSID.Enable + matched AccessPoint.Enable (#972) // ------------------------------------------------------------------------- - group('toggleRadio', () { + group('toggleSsidsByName', () { late MockUspClient mockUsp; late UspWifiSettingsService writeSvc; @@ -786,34 +786,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); + 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.'), + ]); - verify(() => mockUsp.set(any(), allowPartial: any(named: 'allowPartial'))) - .called(1); - }); + final count = await writeSvc.toggleSsidsByName(ssids, aps, 'Home', false); - test('throws UspCompleteFailureError on UspFailure', () async { - when(() => mockUsp.set(any(), allowPartial: any(named: 'allowPartial'))) - .thenAnswer((_) async => uspFailure()); - - 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 +984,37 @@ 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'))); + }); }); // ------------------------------------------------------------------------- @@ -993,7 +1069,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 +1115,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, From 8787ba96e4eeb947327d3470dd97e4b2f4a6ca37 Mon Sep 17 00:00:00 2001 From: Hank Yu Date: Wed, 8 Jul 2026 14:21:16 +0800 Subject: [PATCH 25/56] fix(wifi): ensure L1 cache invalidation on partial failure - Move wifiDataProvider read inside withLock() in toggleSsidsByName to avoid TOCTOU race with concurrent mutations - Wrap ref.refresh/invalidate in finally blocks to ensure L1 cache is always refreshed even when mutations partially fail, keeping UI in sync with firmware state - Apply same fix to updateRadioChannel for consistency --- .../providers/usp_wifi_settings_provider.dart | 63 +++++++++++-------- .../usp_wifi_settings_notifier_test.dart | 5 +- 2 files changed, 39 insertions(+), 29 deletions(-) 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 e40a5f174..b63e899de 100644 --- a/lib/page/wifi_settings/providers/usp_wifi_settings_provider.dart +++ b/lib/page/wifi_settings/providers/usp_wifi_settings_provider.dart @@ -175,26 +175,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); + } } // --------------------------------------------------------------------------- @@ -342,20 +346,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; - final accessPoints = wifiData.codegenContext.raw.accessPoints; - try { final count = await ref.read(uspMutationLockProvider).withLock(() async { - return _svc.toggleSsidsByName(ssids, accessPoints, 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'); @@ -366,8 +374,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/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..7053601cf 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 @@ -593,8 +593,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(); From 5a4c6b549973210ee733949603b88beb66a430e5 Mon Sep 17 00:00:00 2001 From: AustinChangLinksys <79675086+AustinChangLinksys@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:54:42 +0800 Subject: [PATCH 26/56] fix(web): move validation to unfocus to prevent TextField focus loss (#1059) (#1099) On Flutter Web, calling setState during onChanged causes widget tree changes that break TextField's TextInputConnection. This manifests as: - Auto-unfocus when validation error appears/disappears - Cannot delete all text (one character remains) Fix: trigger validation on unfocus instead of onChange for affected pages: - Instant Privacy (Add MAC dialog) - DHCP Reservation (Edit dialog) - Local Network (all IPv4 fields) - DMZ (Destination IP) For AppIpv4TextField, use onFocusChanged callback which only fires (null, false) when focus leaves the entire field, not between segments. Co-authored-by: Claude Opus 4.5 --- .../dialogs/dhcp_reservation_edit_dialog.dart | 22 +++++++- lib/page/dmz/providers/usp_dmz_notifier.dart | 16 +++++- lib/page/dmz/views/usp_dmz_view.dart | 35 +++++++++++-- .../views/instant_privacy_view.dart | 48 ++++++++++++++++-- .../providers/usp_local_network_notifier.dart | 19 +++++-- .../views/usp_local_network_view.dart | 50 +++++++++++++++++-- .../dmz/providers/usp_dmz_notifier_test.dart | 9 ++-- .../usp_local_network_notifier_test.dart | 9 ++-- 8 files changed, 178 insertions(+), 30 deletions(-) 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..d7ef12a69 100644 --- a/lib/page/dhcp/views/dialogs/dhcp_reservation_edit_dialog.dart +++ b/lib/page/dhcp/views/dialogs/dhcp_reservation_edit_dialog.dart @@ -33,6 +33,8 @@ class _DhcpReservationEditDialogState extends State { late TextEditingController _macController; late TextEditingController _ipController; + final _macFocusNode = FocusNode(); + final _ipFocusNode = FocusNode(); late bool _enabled; Map _errors = {}; @@ -49,15 +51,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(); @@ -111,8 +127,8 @@ class _DhcpReservationEditDialogState extends State { }, child: AppTextField( controller: _macController, + focusNode: _macFocusNode, hintText: loc(context).macAddressHint, - onChanged: (_) => _validate(), errorText: _localizeError(_errors['mac']), ), ), @@ -131,8 +147,8 @@ class _DhcpReservationEditDialogState extends State { }, child: AppTextField( controller: _ipController, + focusNode: _ipFocusNode, hintText: loc(context).ipAddressHint, - onChanged: (_) => _validate(), errorText: _localizeError(_errors['ip']), ), ), @@ -163,6 +179,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/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 5b993ac9a..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(); @@ -96,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, ); } @@ -206,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'], ), ), @@ -248,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/instant_privacy/views/instant_privacy_view.dart b/lib/page/instant_privacy/views/instant_privacy_view.dart index b882b9742..a3481e13e 100644 --- a/lib/page/instant_privacy/views/instant_privacy_view.dart +++ b/lib/page/instant_privacy/views/instant_privacy_view.dart @@ -301,10 +301,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 { @@ -328,11 +337,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 @@ -341,17 +352,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; @@ -367,6 +394,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 && @@ -398,11 +430,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/local_network/providers/usp_local_network_notifier.dart b/lib/page/local_network/providers/usp_local_network_notifier.dart index a66cf5590..f07590b98 100644 --- a/lib/page/local_network/providers/usp_local_network_notifier.dart +++ b/lib/page/local_network/providers/usp_local_network_notifier.dart @@ -143,7 +143,10 @@ 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. @@ -171,16 +174,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/views/usp_local_network_view.dart b/lib/page/local_network/views/usp_local_network_view.dart index 5bccc3d0d..9d2c786b4 100644 --- a/lib/page/local_network/views/usp_local_network_view.dart +++ b/lib/page/local_network/views/usp_local_network_view.dart @@ -37,6 +37,9 @@ class _UspLocalNetworkViewState extends ConsumerState { late TextEditingController _dns2Controller; late TextEditingController _dns3Controller; + final _hostNameFocus = FocusNode(); + final _leaseTimeFocus = FocusNode(); + @override void initState() { super.initState(); @@ -49,10 +52,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(); @@ -129,13 +154,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, ); } @@ -185,6 +216,7 @@ class _UspLocalNetworkViewState extends ConsumerState { children: [ AppTextFormField( controller: _hostNameController, + focusNode: _hostNameFocus, label: loc(context).hostname, onChanged: (v) => notifier.updateSetting((m) => m.copyWith(hostName: v)), @@ -197,6 +229,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, ), @@ -206,6 +239,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, ), @@ -271,6 +305,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, @@ -281,6 +316,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, @@ -288,6 +324,7 @@ class _UspLocalNetworkViewState extends ConsumerState { AppGap.md(), AppTextFormField( controller: _leaseTimeController, + focusNode: _leaseTimeFocus, label: loc(context).leaseTimeMinutes, keyboardType: TextInputType.number, onChanged: (v) { @@ -315,6 +352,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, ), @@ -324,6 +362,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, ), @@ -333,6 +372,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, ), 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/local_network/providers/usp_local_network_notifier_test.dart b/test/page/local_network/providers/usp_local_network_notifier_test.dart index 6c48caf2a..cbfa33785 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 @@ -94,14 +94,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 +125,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 +133,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 +148,7 @@ void main() { final notifier = container.read(uspLocalNetworkProvider.notifier); notifier.updateSetting((m) => m.copyWith(hostName: 'X')); + notifier.validate(); expect( container .read(uspLocalNetworkProvider) From 31e4667bf360007805aaad5c5dbbec1dcf2caf57 Mon Sep 17 00:00:00 2001 From: AustinChangLinksys <79675086+AustinChangLinksys@users.noreply.github.com> Date: Wed, 8 Jul 2026 18:08:08 +0800 Subject: [PATCH 27/56] fix(dhcp): reject duplicate MAC/IP in reservation dialog (#1070) (#1078) * fix(dhcp): reject duplicate MAC/IP in reservation dialog (#1070) The DHCP reservation Add/Edit dialog validated only MAC format, IPv4 format, and the reserved-IP rule; it never compared the entered MAC/IP against existing reservations, so a duplicate was accepted and sent as a USP ADD request (backend accepts it). Add duplicate detection to the dialog's _validate(): the caller now passes the current reservation list via existingReservations, and the entered MAC (case-insensitive) / IP is rejected if it collides with any other reservation. When editing, the reservation being edited is excluded so it can keep its own address. Wire both detail-card callers (add + edit) to supply the list, and add duplicateMacAddress / duplicateIpAddress l10n keys. * fix(dhcp): exclude self by stable instancePath in reservation duplicate check The DHCP reservation edit dialog excluded the edited reservation from the duplicate MAC/IP check via Equatable value-equality (r != widget.reservation). Because DhcpReservationUIModel.props includes the non-key 'enable' field, an SSE-driven re-fetch that toggles the edited entry's enable flag while the dialog is open makes value-equality fail to match self, so the user's own unchanged MAC/IP is falsely flagged as a duplicate and Save is disabled. Exclude self by stable instancePath identity instead; fall back to identical() for not-yet-saved local reservations (null instancePath). Adds a regression test reproducing the SSE enable-drift scenario (fails on the old value-equality filter, passes with the identity-based fix). --- lib/l10n/app_en.arb | 2 + .../usp_dhcp_reservations_detail_card.dart | 2 + .../dialogs/dhcp_reservation_edit_dialog.dart | 36 +++ .../dhcp_reservation_edit_dialog_test.dart | 217 ++++++++++++++++++ 4 files changed, 257 insertions(+) create mode 100644 test/page/dhcp/views/dialogs/dhcp_reservation_edit_dialog_test.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index bc798ed22..a8fedb4c6 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -868,6 +868,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})", 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 e6ae9679d..d029183af 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 @@ -137,6 +137,7 @@ class UspDhcpReservationsDetailCard extends ConsumerWidget { builder: (_) => DhcpReservationEditDialog( macDeviceOptions: options.mac, ipDeviceOptions: options.ip, + existingReservations: reservations, ), ); if (result == null || !context.mounted) return; @@ -161,6 +162,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 d7ef12a69..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 @@ -91,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); } @@ -100,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, }; } 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..fae2e996e --- /dev/null +++ b/test/page/dhcp/views/dialogs/dhcp_reservation_edit_dialog_test.dart @@ -0,0 +1,217 @@ +@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'}), +); + +const _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 = _existing, +}) async { + ({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: existing, + ), + ); + 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(); +} + +Future _enterIp(WidgetTester tester, String text) async { + await tester.enterText(find.byType(TextField).at(1), text); + 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. + const 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). + const 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: const [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); + }); + }); +} From 9028ec6ec2d1abd286dc54e4d1bcf00c6bea6dc8 Mon Sep 17 00:00:00 2001 From: AustinChangLinksys <79675086+AustinChangLinksys@users.noreply.github.com> Date: Wed, 8 Jul 2026 18:20:44 +0800 Subject: [PATCH 28/56] fix(dhcp): validate reservations added from Dashboard card (#1067) (#1077) * fix(dhcp): validate reservations added from Dashboard card (#1067) The Dashboard DHCP card invoked the unvalidated DhcpReservationDialog, which only silently no-op'd on empty MAC/IP and applied no format validation. Malformed/empty reservations were therefore accepted and persisted via AddInstance. Point the Dashboard card at the existing DhcpReservationEditDialog (already used by the DHCP detail page), which enforces MAC-address and IPv4 format rules plus reserved-IP checks and disables the Add button until the form is valid. Remove the now-orphaned DhcpReservationDialog. Refs #1067 * chore: trigger CI (empty commit) Re-trigger pull_request CI for #1077 (base changed to dev-2.6.0 did not fire sync). No file changes. --- .../dialogs/dhcp_reservation_dialog.dart | 73 ------------------- .../cards/usp_dhcp_reservations_card.dart | 4 +- 2 files changed, 2 insertions(+), 75 deletions(-) delete mode 100644 lib/page/dashboard/views/dialogs/dhcp_reservation_dialog.dart 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/local_network/cards/usp_dhcp_reservations_card.dart b/lib/page/local_network/cards/usp_dhcp_reservations_card.dart index 09ad050f0..b99b94278 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 { @@ -127,7 +127,7 @@ class UspDhcpReservationsCard extends ConsumerWidget { Future _showAddDhcpDialog(BuildContext context, WidgetRef ref) async { final result = await showAppDialog<({String mac, String ip, bool enable})>( context: context, - builder: (_) => const DhcpReservationDialog(), + builder: (_) => const DhcpReservationEditDialog(), ); if (result == null || !context.mounted) return; await performUspMutation( From fbe38ca9b183dca9a267eda5bf844956603f4653 Mon Sep 17 00:00:00 2001 From: Peter Jhong <52424995+PeterJhongLinksys@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:25:12 +0800 Subject: [PATCH 29/56] refactor(dhcp): centralize reservation device-option data source (#1109) * fix(hooks): use absolute paths in PreToolUse hooks The PreToolUse Bash hooks used a relative path (.claude/hooks/pr_gate.py) and relied on the shell's working directory. When the working directory changed (e.g. to a subdirectory), the hook could no longer locate the script and blocked all Bash commands. Use $CLAUDE_PROJECT_DIR to resolve the pr_gate.py path, and cd into the project root in the dart-format hook so staged file paths resolve correctly regardless of the current working directory. Co-Authored-By: Claude Opus 4.8 * refactor(dhcp): move device option data source into reservations notifier Extract the client-device data lookup out of the reservation cards and into UspDhcpReservationsNotifier.deviceOptions(), which returns pure data (ReservationDeviceOption records) instead of UI Kit types. The cards now map that data to AppAutoCompleteOption at the call site, keeping the cross-provider read in the notifier and the UI projection in the view. - Wire device autocomplete options into the local-network add dialog. - Add duplicateMacAddress / duplicateIpAddress strings across all 26 locales. - Add unit tests for deviceOptions() (mapping, name fallback, mesh-node exclusion, empty case). Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .claude/settings.json | 4 +- lib/l10n/app_ar.arb | 2 + lib/l10n/app_da.arb | 2 + lib/l10n/app_de.arb | 2 + lib/l10n/app_el.arb | 2 + lib/l10n/app_es.arb | 2 + lib/l10n/app_es_ar.arb | 2 + lib/l10n/app_fi.arb | 2 + lib/l10n/app_fr.arb | 2 + lib/l10n/app_fr_ca.arb | 2 + lib/l10n/app_id.arb | 2 + lib/l10n/app_it.arb | 2 + lib/l10n/app_ja.arb | 2 + lib/l10n/app_ko.arb | 2 + lib/l10n/app_nb.arb | 2 + lib/l10n/app_nl.arb | 2 + lib/l10n/app_pl.arb | 2 + lib/l10n/app_pt.arb | 2 + lib/l10n/app_pt_pt.arb | 2 + lib/l10n/app_ru.arb | 2 + lib/l10n/app_sv.arb | 2 + lib/l10n/app_th.arb | 2 + lib/l10n/app_tr.arb | 2 + lib/l10n/app_vi.arb | 2 + lib/l10n/app_zh.arb | 2 + lib/l10n/app_zh_TW.arb | 2 + .../usp_dhcp_reservations_notifier.dart | 29 ++++ .../usp_dhcp_reservations_detail_card.dart | 9 +- .../cards/usp_dhcp_reservations_card.dart | 31 +++- .../usp_dhcp_reservations_notifier_test.dart | 134 ++++++++++++++++++ 30 files changed, 250 insertions(+), 7 deletions(-) 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/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index c4616e5f6..ce16214b8 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -474,6 +474,8 @@ "confirmationRequired": "التأكيد مطلوب", "confirmingNewFirmware": "جارٍ التأكد من تشغيل البرنامج الثابت الجديد…", "connect": "اتصال", + "duplicateMacAddress": "عنوان MAC هذا محجوز بالفعل", + "duplicateIpAddress": "عنوان IP هذا محجوز بالفعل", "connectedDevices": "الأجهزة المتصلة", "connecting": "جارٍ الاتصال...", "connectingToRouter": "جارٍ الاتصال بجهاز التوجيه...", diff --git a/lib/l10n/app_da.arb b/lib/l10n/app_da.arb index c045bf7f2..835592b23 100644 --- a/lib/l10n/app_da.arb +++ b/lib/l10n/app_da.arb @@ -473,6 +473,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...", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index a44827e0c..6549e50c1 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -473,6 +473,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...", diff --git a/lib/l10n/app_el.arb b/lib/l10n/app_el.arb index 68152b4cd..a3fa21f91 100644 --- a/lib/l10n/app_el.arb +++ b/lib/l10n/app_el.arb @@ -471,6 +471,8 @@ "confirmationRequired": "Απαιτείται επιβεβαίωση", "confirmingNewFirmware": "Επιβεβαίωση ότι εκτελείται το νέο firmware…", "connect": "Σύνδεση", + "duplicateMacAddress": "Αυτή η διεύθυνση MAC έχει ήδη δεσμευτεί", + "duplicateIpAddress": "Αυτή η διεύθυνση IP έχει ήδη δεσμευτεί", "connectedDevices": "Συνδεδεμένες συσκευές", "connecting": "Σύνδεση...", "connectingToRouter": "Σύνδεση με τον δρομολογητή...", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 5f38da38a..85d0b7237 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -473,6 +473,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...", diff --git a/lib/l10n/app_es_ar.arb b/lib/l10n/app_es_ar.arb index 7fc66cd49..295fce98c 100644 --- a/lib/l10n/app_es_ar.arb +++ b/lib/l10n/app_es_ar.arb @@ -473,6 +473,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...", diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb index 6a316390a..c0f7e4e4b 100644 --- a/lib/l10n/app_fi.arb +++ b/lib/l10n/app_fi.arb @@ -471,6 +471,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...", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index bb4b38d89..2b7ea7ebe 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -473,6 +473,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...", diff --git a/lib/l10n/app_fr_ca.arb b/lib/l10n/app_fr_ca.arb index 03f4afea5..4d7c244eb 100644 --- a/lib/l10n/app_fr_ca.arb +++ b/lib/l10n/app_fr_ca.arb @@ -473,6 +473,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...", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 1ad97080a..a85987c88 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -471,6 +471,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...", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index d95e26794..d265bb094 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -473,6 +473,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...", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 48952628d..0849626a6 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -474,6 +474,8 @@ "confirmationRequired": "確認が必要です", "confirmingNewFirmware": "新しいファームウェアが動作していることを確認中…", "connect": "接続", + "duplicateMacAddress": "この MAC アドレスはすでに予約されています", + "duplicateIpAddress": "この IP アドレスはすでに予約されています", "connectedDevices": "接続中のデバイス", "connecting": "接続中...", "connectingToRouter": "ルーターに接続中...", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index e7b369ac0..1e32f1c6d 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -471,6 +471,8 @@ "confirmationRequired": "확인 필요", "confirmingNewFirmware": "새 펌웨어가 실행 중인지 확인하는 중…", "connect": "연결", + "duplicateMacAddress": "이 MAC 주소는 이미 예약되어 있습니다", + "duplicateIpAddress": "이 IP 주소는 이미 예약되어 있습니다", "connectedDevices": "연결된 장치", "connecting": "연결 중...", "connectingToRouter": "라우터에 연결 중...", diff --git a/lib/l10n/app_nb.arb b/lib/l10n/app_nb.arb index 9e5930ae3..d15b10157 100644 --- a/lib/l10n/app_nb.arb +++ b/lib/l10n/app_nb.arb @@ -473,6 +473,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...", diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 4af9d2be0..183fc7eb4 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -473,6 +473,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...", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index fffafcce6..f67a24f25 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -471,6 +471,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...", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index d95771258..54acbb43b 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -473,6 +473,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...", diff --git a/lib/l10n/app_pt_pt.arb b/lib/l10n/app_pt_pt.arb index 937751d02..5197cf382 100644 --- a/lib/l10n/app_pt_pt.arb +++ b/lib/l10n/app_pt_pt.arb @@ -473,6 +473,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...", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 66f263ee2..af58799be 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -471,6 +471,8 @@ "confirmationRequired": "Требуется подтверждение", "confirmingNewFirmware": "Подтверждение запуска новой прошивки…", "connect": "Подключить", + "duplicateMacAddress": "Этот MAC-адрес уже зарезервирован", + "duplicateIpAddress": "Этот IP-адрес уже зарезервирован", "connectedDevices": "Подключенные устройства", "connecting": "Подключение...", "connectingToRouter": "Подключение к маршрутизатору...", diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index c87d3d0ee..c17f5b799 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -473,6 +473,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...", diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index baf7684e5..fc3ff6fc2 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -471,6 +471,8 @@ "confirmationRequired": "ต้องมีการยืนยัน", "confirmingNewFirmware": "กำลังยืนยันว่าเฟิร์มแวร์ใหม่กำลังทำงาน…", "connect": "เชื่อมต่อ", + "duplicateMacAddress": "ที่อยู่ MAC นี้ถูกสำรองไว้แล้ว", + "duplicateIpAddress": "ที่อยู่ IP นี้ถูกสำรองไว้แล้ว", "connectedDevices": "อุปกรณ์ที่เชื่อมต่อ", "connecting": "กำลังเชื่อมต่อ...", "connectingToRouter": "กำลังเชื่อมต่อกับเราเตอร์...", diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index 7c70318fd..da600c49a 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -471,6 +471,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...", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 836b51331..3625612e5 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -471,6 +471,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...", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index f8a5029e5..791fb3ca2 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -474,6 +474,8 @@ "confirmationRequired": "需要确认", "confirmingNewFirmware": "正在确认新固件是否运行…", "connect": "连接", + "duplicateMacAddress": "此 MAC 地址已被保留", + "duplicateIpAddress": "此 IP 地址已被保留", "connectedDevices": "已连接的设备", "connecting": "连接中…", "connectingToRouter": "正在连接路由器…", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index f9f53f040..3fec4dc08 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -474,6 +474,8 @@ "confirmationRequired": "需要確認", "confirmingNewFirmware": "正在確認新韌體是否正在執行…", "connect": "連線", + "duplicateMacAddress": "此 MAC 位址已被保留", + "duplicateIpAddress": "此 IP 位址已被保留", "connectedDevices": "已連線的裝置", "connecting": "正在連線...", "connectingToRouter": "正在連線至路由器...", 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_reservations_detail_card.dart b/lib/page/dhcp/views/components/usp_dhcp_reservations_detail_card.dart index d029183af..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?.clientDevices ?? []; + 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, 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 b99b94278..96ee8b06d 100644 --- a/lib/page/local_network/cards/usp_dhcp_reservations_card.dart +++ b/lib/page/local_network/cards/usp_dhcp_reservations_card.dart @@ -125,9 +125,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 DhcpReservationEditDialog(), + builder: (_) => DhcpReservationEditDialog( + macDeviceOptions: options.mac, + ipDeviceOptions: options.ip, + ), ); if (result == null || !context.mounted) return; await performUspMutation( @@ -144,6 +148,31 @@ class UspDhcpReservationsCard extends ConsumerWidget { ); } + ({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( 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..227077072 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,9 @@ 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/device_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'; 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 +356,141 @@ 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( + deviceModels: [ + _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( + deviceModels: [ + _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 => []); + final container = createContainerWithDevices(DevicesData( + deviceModels: [ + _device( + mac: 'AA:BB:CC:DD:EE:01', + ip: '192.168.1.10', + deviceRole: 'client'), + _device( + mac: 'AA:BB:CC:DD:EE:02', + ip: '192.168.1.1', + deviceRole: 'master'), + _device( + mac: 'AA:BB:CC:DD:EE:03', ip: '192.168.1.2', deviceRole: 'slave'), + ], + )); + await container.read(devicesDataProvider.future); + await Future.delayed(Duration.zero); + + final options = + container.read(uspDhcpReservationsProvider.notifier).deviceOptions(); + + expect(options, hasLength(1)); + expect(options[0].mac, 'AA:BB:CC:DD:EE:01'); + container.dispose(); + }); + + test('deviceOptions returns empty when no devices', () async { + when(() => mockService.fetchReservations()).thenAnswer((_) async => []); + final container = createContainerWithDevices(const 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; +} + +DeviceUIModel _device({ + required String mac, + required String ip, + String hostName = '', + String? friendlyName, + bool isActive = true, + String? deviceRole, +}) { + return DeviceUIModel( + mac: mac, + ip: ip, + hostName: hostName, + isActive: isActive, + isWifi: false, + friendlyName: friendlyName, + deviceRole: deviceRole, + ); +} + /// Test notifier that tracks invalidation. class _TestDhcpDataNotifier extends DhcpDataNotifier { final void Function()? onInvalidate; From 3cbee8b1eb364a0f7a5ad84dfde3628a10b8d17d Mon Sep 17 00:00:00 2001 From: Peter Jhong <52424995+PeterJhongLinksys@users.noreply.github.com> Date: Thu, 9 Jul 2026 17:07:11 +0800 Subject: [PATCH 30/56] feat(internet): support PPTP, L2TP, and Bridge WAN connection types (#1094) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: bump version to 2.5.0 and add test report generation - Update version from 2.4.0 to 2.5.0 in pubspec.yaml - Add --report flag to run_tests.sh for markdown test report output - Add standalone tools/test_report.sh for generating categorized reports Co-Authored-By: Claude Opus 4.5 * test(firmware-update): add golden tests for all firmware update states Add comprehensive golden test coverage for the firmware update page, covering all 16 visual states across phone and desktop viewports. Firmware Update View states (12): - idle_no_file, idle_file_selected - picking, validating, uploading - triggering, installing, rebooting, verifying - done, failed, banks_empty Recovery Dialog states (4): - waiting_initial, waiting_unreachable - wifi_warning, serial_mismatch Files added: - mock_firmware_update.dart: Provider overrides for golden tests - firmware_update_test_data.dart: Test fixtures and state builders - firmware_update_view_test.dart: Main view golden tests - firmware_recovery_dialog_test.dart: Recovery dialog golden tests Co-Authored-By: Claude Opus 4.5 * feat(skill): add golden test coverage check to review-pr-readiness Add Phase 4.5 to review-pr-readiness skill for checking golden test coverage when View files are changed. The new checks include: - 4.5.1: Golden test file existence for changed views - 4.5.2: Golden test freshness when views are modified - 4.5.3: Deep analysis of view states vs golden test coverage - 4.5.4: Mock and fixture file existence - 4.5.5: Optional golden test execution verification This ensures PR reviewers are prompted to add/update golden tests when visual changes are made, maintaining screenshot test coverage. Co-Authored-By: Claude Opus 4.5 * refactor(pnp): unify troubleshoot flow UI patterns and behavior (#907) Review pass over the PnP no-internet troubleshoot flow (no-internet hub, unplug-modem, modem-lights-off, waiting-modem, isp selection, pppoe, static IP). Establishes consistent layout patterns and fixes save-flow behavior gaps so the troubleshoot pages feel like the rest of the app. Layout — onboarding/wizard pages - Wrap content in `withSliver` + `Center` + `ConstrainedBox(maxWidth: 480)` + `EdgeInsets.all(xl)` so cards stop stretching across desktop viewports. - Replace sticky `UiKitBottomBarConfig` with inline `AppButton.primary` at the bottom of the column — onboarding flows are linear, the next action belongs in content flow, not a sticky save bar. - Standardize illustration width (160px) and crossAxisAlignment. Layout — full-screen status overlays (saving / countdown / checking) - Switch from `withSliver` to plain `UiKitPageView` with `useMainPadding: false`, then wrap content in `Center`. `withSliver` collapses children to intrinsic height (so MainAxisAlignment.center has no room) and `useMainPadding: true` applies grid pageMargin (which pushes overlays off-center on wide screens). Save flow - Extract `PnpIspSavingProgress` shared widget so DHCP, PPPoE, and Static IP all show the same three-step progress UI. - Add proper localization keys for the save-step labels (previously borrowed unrelated strings like "Save" and the ISP-type page title). - Surface save failures via SnackBar — `errorMessage` was being written to state but no view consumed it. - Disable the form Save button until required fields are filled. - Show button loading state on the no-internet "Try again" button. Save responsibility boundary - Convert `PnpIspSettingsView` to `ConsumerStatefulWidget`. DHCP save is driven by a local `_dhcpSaving` flag and a one-shot post-await phase read; the page no longer keeps a `ref.listen` on the global PnP phase. This prevents the parent index page from reacting to save outcomes triggered by its child form pages (PPPoE / Static IP), which would otherwise cause double-fired SnackBars and unnecessary rebuilds. * feat(firmware-update): add OTA firmware update support (#917) * feat(firmware-update): add OTA firmware update support Add cloud API integration to check for available firmware updates and trigger OTA download directly to router. OTA and local manual upload share the same flow from FirmwareImage.Download() onwards (flash → reboot → verify). - Add FirmwareOtaCheckService for cloud API integration - Add FirmwareOtaInfo model for API response parsing - Add checkingOta phase and OTA state fields - Add OTA check UI card with "Check for Updates" button - Add triggerOtaDownload() for remote firmware URL * test(firmware-update): add tests for OTA update functionality - Add FirmwareOtaInfo model tests (JSON parsing, toQueryParams) - Add FirmwareOtaCheckService tests (HTTP calls, error handling) - Add triggerOtaDownload service tests - Add checkForOtaUpdate and triggerOtaInstall notifier tests * refactor(firmware-update): move OTA param building to notifier layer Address code review feedback: - Fix: Remove PII (MAC/IP) from log by only logging URI path - Fix: Move OTA check param building logic from View to Notifier - buildOtaCheckParams(), _formatMacAddress(), _parseHardwareVersion() - View now only calls notifier methods, no business logic * fix(firmware-update): 1. Make releaseDate nullable instead of using DateTime.now() fallback - Prevents non-deterministic behavior in Equatable comparison - null semantics correctly represent "not provided" 2. Add clearOtaInfo flag to FirmwareUpdateState.copyWith() - Allows resetting otaInfo to null when needed - Pattern: copyWith(clearOtaInfo: true) * chore: upgrade Flutter 3.38.5 → 3.44.0 and dependencies (#914) * chore: upgrade Flutter 3.38.5 → 3.44.0 - Update .fvmrc to pin Flutter 3.44.0 (Dart 3.12.0) - Update vendored CanvasKit for offline web deployment Co-Authored-By: Claude Opus 4.5 * chore(deps): remove unused packages and upgrade for SPM support Remove unused packages: - connectivity_plus (not used in codebase) - network_info_plus (not used in codebase) - flutter_local_notifications (not used in codebase) Upgrade packages for Swift Package Manager support: - flutter_secure_storage: 9.2.2 → 10.3.1 - device_info_plus: 9.1.2 → 11.1.0 - package_info_plus: 4.1.0 → 8.1.0 - share_plus: 7.1.0 → 10.1.0 - printing: 5.13.1 → 5.14.3 SPM warnings reduced from 11 to 4. Co-Authored-By: Claude Opus 4.5 * chore(deps): upgrade go_router 14.2.8 → 17.0.0 No breaking changes affecting current codebase. All 2630 tests pass. Co-Authored-By: Claude Opus 4.5 * chore(deps): remove unused permission_handler - Remove lib/util/permission.dart (Permissions mixin never used) - Remove permission_handler dependency from pubspec.yaml SPM warnings reduced from 4 to 3. Co-Authored-By: Claude Opus 4.5 * chore(deps): upgrade ui_kit_library v2.20.0 → v2.21.1 Co-Authored-By: Claude Opus 4.5 * feat(mascot): integrate mascot overlay with dashboard (#922) * feat(mascot): integrate mascot overlay with random speech - Add mascot integration to USP dashboard shell - Implement DashboardDialogProvider with FAQ, diagnostics, print report - Add random speech timer (10-30s interval, auto-hide after 5s) - Add mascot toggle in GeneralSettingsWidget - Redesign GeneralSettingsWidget layout (unified row height, AppSwitch) - Fix ThemeModeTile to use dialog selection pattern - Fix Theme Studio persistence with keepAlive - Fix popup dismiss behavior with TapRegion groupId - Show mascot only after dashboard data is ready - Upgrade ui_kit_library v2.21.1 → v2.23.1 Co-Authored-By: Claude Opus 4.5 * refactor(mascot): simplify coordinator and add unit tests - Rename mascotRandomSpeechProvider → mascotCoordinatorProvider - Move startup logic from shell to MascotCoordinatorNotifier.build() - Remove complex ref.listen/Future.microtask from shell - Delete unused network_health_score.dart - Add dashboard_dialog_provider_test.dart (13 tests) - Add mascot_coordinator_notifier_test.dart (4 tests) Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 * feat: mesh topology enhancement with layout system refactor (#915) * feat(codegen): update usp-codegen with resolveBy and regenerate .g.dart files - Update tools/usp-codegen binary to v0.15.2 - Add resolveBy feature for dynamic WAN/LAN interface resolution via Alias - Fix absolute path handling bug (DHCP paths no longer incorrectly prefixed) - Add new MeshNode backhaul fields: BackhaulDeviceID, BackhaulMACAddress, LinkType, MACAddress, LastDataDownlinkRate - Regenerate all .g.dart files with new codegen - Update mesh_topology_builder_test.dart for new MeshNode fields Closes #909 Co-Authored-By: Claude Opus 4.5 * feat(topology): mesh topology enhancement phase 2 with diagnostics integration - Consolidate RSSI thresholds to single source (wifi.dart) - Merge wifi_performance_helpers.dart into wifi.dart/wifi_ui.dart - Add DataElements enrichment fields to NodeUIModel for backhaul diagnostics - Add mesh backhaul check to unified diagnostics service - Fix signal strength display with text labels and proper units - Change diagnostic results from GridView to Wrap for flexible height - Use dialog instead of bottom sheet for diagnostic details (desktop UX) - Update stale threshold from 5 to 10 minutes - Update codegen to v0.15.3 with resolveBy fix for updateOrdered - Fix test mocks for _resolveInstance get calls Co-Authored-By: Claude Opus 4.5 * refactor(ui): unify speed formatting and enhance backhaul card design - Consolidate speed formatting to NetworkUtils.formatSpeed/formatSpeedWithUnit - Single source of truth for kbps → human-readable conversion - Gbps: 2 decimal places, Mbps: 0 decimals, kbps: no decimals - Update DetailSpeedCard to use speedKbps parameter (TR-181 standard unit) - Enhance BackhaulSignalIndicator with visual bar design matching Device Detail - Fix PHY Rate display to use unified formatting (Mbps → Gbps when applicable) - Remove duplicate formatSpeed from wifi.dart and node_detail_popup.dart - Fix rate field comments (was incorrectly documented as bps, actually kbps) Co-Authored-By: Claude Opus 4.5 * fix(ui): unify upload speed card color to tertiary Device Detail was using secondary for upload while Node Detail used tertiary. Unified to tertiary (upload) and primary (download) across both views for visual consistency. Co-Authored-By: Claude Opus 4.5 * refactor(ui): introduce layout blocks system and unify dashboard cards - Create reusable layout blocks library (lib/page/_shared/components/layout_blocks/) - Block: universal base wrapper with consistent background styling - CardHeader: fixed 36px height for consistent title alignment - StatusBlock, AlertBanner: status indicators - InfoGrid, InfoList, ListPreview: data display blocks - HighlightValue, DualMetric, StatTile: metric blocks - NetworkRow, DeviceRow, DataRow, StatusRow: row blocks - ProgressBlock, QuotaBlock, RangeBlock: data visualization blocks - Redesign all dashboard cards using Block-based layout patterns: - Hero block + metric tiles + InfoGrid design pattern - Consistent visual styling across all cards - All progress bars now use UI Kit AppLoader (linear variant) - Fix multi-interface device detection in Ethernet ports card - Now correctly shows wired connections from devices with both WiFi and Ethernet - Update dashboard presets and widget specs for proper card sizing Co-Authored-By: Claude Opus 4.5 * refactor(ui): apply Block layout pattern across all feature pages Consistently apply Block component pattern throughout the application for unified visual hierarchy and semantic grouping within AppCard containers. Also update tests to match model changes from prior sessions. Co-Authored-By: Claude Opus 4.5 * chore: remove design showcase page and routes The Block layout pattern has been applied across all feature pages, so the showcase page is no longer needed for development reference. Co-Authored-By: Claude Opus 4.5 * refactor(layout-blocks): consolidate design system and remove unused components - Add BlockConstants for unified alpha, padding, borderRadius values - Extract SwitchBlock, SettingBlock, NavLinkBlock, FormFieldBlock - Remove unused: StatusBlock, AlertBanner, IpAddressBlock, ProgressBlock, QuotaBlock, RangeBlock, HighlightValue, DualMetric, VersionBlock, ComparisonBlock, ListPreview, NetworkRow, DataRow, StatusRow, ToggleListItem, SplitRow, SectionDivider, CountBadge - Apply SwitchBlock to Firewall view (removes _switchRow helper) - Apply SettingBlock to WiFi Network Card (removes _SettingBlock) Reduces layout_blocks from 8 files (~850 lines) to 6 files (~400 lines) while adding reusable patterns for common UI elements. Co-Authored-By: Claude Opus 4.5 * docs(constitution): add Article XIV Layout Composition Patterns Define project-level layout conventions that complement UI Kit: - Block pattern: visual grouping container (surfaceContainerHighest @ 50%) - Three usage patterns: Card+Block, Block alone, Card alone - Shared components: SwitchBlock, SettingBlock, NavLinkBlock, DeviceRow - Implementation rules and file organization Renumber UI Kit Library Principle to Article XV. Co-Authored-By: Claude Opus 4.5 * feat(dashboard): adjust traffic monitor timer to Off/10s/30s/60s Change the Traffic Monitor refresh interval options from Off/2s/5s/10s to Off/10s/30s/60s, with 10s as the new default. Co-Authored-By: Claude Opus 4.8 * refactor(DeviceRow): use AppListTile from UI Kit Replace custom Row/Container layout with AppListTile to comply with UI Kit First principle. Preserves icon container styling via leading parameter. Co-Authored-By: Claude Opus 4.5 * refactor(architecture): fix constitution compliance and clean up core layer Article XIII compliance: - Add error mapping to PnpService (6 methods) - Create DiagnosticsScopeService to wrap NetworkDiagnosticsExecutor - Remove usp_error.dart imports from provider layer (speed_test_notifier, manual_tools_notifier) SSoT fixes: - Consolidate MeshBackhaulSeverityBucket into MeshBackhaulSeverity enum - Remove switch conversion in unified_diagnostics_notifier Core layer cleanup (remove Flutter Material imports): - Move device_classifier.dart to lib/page/_shared/utils/ - Move recovery_dialog_helper.dart to lib/page/_shared/helpers/ - Extract DeviceConnectionTypeExt to lib/page/_shared/extensions/ Test updates: - Move device_classifier_test.dart to test/page/_shared/utils/ - Add DiagnosticsScopeService unit tests (20 test cases) - Update test imports and enum references Co-Authored-By: Claude Opus 4.5 * fix(review): address PR #915 review comments - Fix speed row UX: show only available directions instead of '--' - Extract shared MetricTile to layout_blocks (remove duplication) - Refactor NetworkBadgeWidget to use AppBadge from UI Kit (icon support pending #916) Co-Authored-By: Claude Opus 4.5 * refactor(layout-blocks): rename Block to LayoutBlock Avoid name collision with go_router.Block Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 * feat(usp): update WASM client with UspClientBuilder support Add builder pattern for creating UspClient with custom configuration: - authToken(): set Bearer token (skip login flow) - endpoint(): set custom USP endpoint path - extraHeader(): add custom HTTP headers - build(): create UspClient instance Required for Remote Assistance integration via Guardian API. Co-Authored-By: Claude Opus 4.5 * feat(usp): add Remote Assistance POC via Guardian proxy Add support for Remote Assistance mode that allows remote control of router via Guardian API using temporary access token. Changes: - Add UspClientBuilderJS WASM binding for builder pattern - Add UspClientWeb.fromJsClient() and UspClient.fromBuilder() factories - Add RemoteAssistanceProvider for RA state management - Add RemoteAssistanceConfirmView for token input UI - Add /remoteAssistance route with ?sessionId query param - Add URL detection: /?ra_session=xxx redirects to RA confirm page Usage: 1. Navigate to http://localhost:5000/?ra_session=test-session-123 2. Enter temporary access token on confirm page 3. Click Connect to initialize Guardian-proxied USP client 4. Dashboard loads with USP operations routed through Guardian Co-Authored-By: Claude Opus 4.5 * feat(build): add force=remote build mode for Remote Assistance - Add ForceCommand.remote enum value - Add BuildConfig.isRemote() helper - Redirect to /remoteAssistance when force=remote is set Usage: flutter run --dart-define force=remote Or use VSCode "linksys - Web (Remote)" launch config. Co-Authored-By: Claude Opus 4.5 * feat(config): add GlobalConfig with ThemeConfig integration - Rename feature_flags.json to app_config.json with new structure - Add ThemeConfig to GlobalConfig for CI/CD theme configuration - Extract ThemeSource enum to separate file to avoid circular import - ThemeConfigLoader now reads from GlobalConfig.theme if configured - Add app_config.json.template with full schema documentation The theme section in app_config.json is optional - when absent, ThemeConfigLoader falls back to dart-define environment variables. Co-Authored-By: Claude Opus 4.5 * fix(usp): add conditional export for UspClientBuilderJS - Create usp_client_builder.dart as platform-agnostic entry point - Add usp_client_builder_stub.dart for VM/tests - Add usp_client_builder_web.dart to re-export from WASM - Fix test failures caused by unconditional WASM import The previous direct export of UspClientBuilderJS from usp_client_wasm.dart caused dart:js_interop to be imported on non-web platforms, breaking tests. Co-Authored-By: Claude Opus 4.5 * test: update tests for auth check architecture change - Remove isAuthenticated getter tests from service tests - Update notifier tests to use appConnectionStateProvider for auth check - Remove obsolete unauthenticated service test from internet settings Auth checks moved from Service layer (isAuthenticated getter) to Provider layer (appConnectionStateProvider). Services are now stateless and trust the upper layer handles auth before navigation. Co-Authored-By: Claude Opus 4.5 * feat(remote): complete Remote Assistance mode implementation Remote Assistance mode allows support agents to view router status through Guardian proxy without direct network access. Changes: - Add RemoteAccessProvider with sessionStorage persistence for refresh - Add GlobalConfig.remote for centralized UI/feature restrictions - Skip SSE in Remote mode (Guardian proxy limitation) - Fix router redirect loop after Connect - Use fixed remote preset layout for Dashboard - Add topology card to remote preset - Remove isAuthenticated checks from Services (moved to Provider layer) - Simplify General Settings in Remote mode (hide Legal, Logout) - Add RemoteSessionChip for session info display Known limitations: - Operate-based diagnostics (Ping/Traceroute) don't work in Remote mode due to SSE dependency for OperationComplete events Co-Authored-By: Claude Opus 4.5 * fix(remote): improve Remote Assistance UX - Change query param key from 'sessionId' to 'session' - Show expiry time instead of countdown in popup (fixed value) - Add session polling every 30s to sync remaining time with server - Fix End Session redirect to show session ended view - Use go() instead of goNamed() to clear navigation history Co-Authored-By: Claude Opus 4.5 * fix(remote): fix End Session navigation race condition - Capture GoRouter before async gap to avoid context unmount issue - Delay logout() until after navigation completes via postFrameCallback - Clean up unused cloud_const.dart constants (30+ unused entries removed) - Add guardianDomain constant for future domain migration Co-Authored-By: Claude Opus 4.5 * feat(dashboard): unify card templates and enhance AI assistant (#931) * refactor(dashboard): unify card layout with DashboardCardTemplate Extract common card structure (header, scrollable content, footer) into a reusable template supporting three modes: - Single content: standard cards - Multi-section: composite cards (DHCP, Port Forwarding) - Tabbed: cards with tab navigation (System Status, Analytics) Migrated 18 dashboard cards to use the template, reducing ~225 lines of duplicated layout code while ensuring consistent visual appearance. Co-Authored-By: Claude Opus 4.5 * feat(ai): add modular Section architecture and new AI commands Router AI Assistant enhancements: ## Modular Section Architecture - Add 9 domain sections: WanSection, LanSection, WifiSection, DevicesSection, SystemSection, FirewallSection, EthernetSection, DhcpSection, PortForwardingSection - Add 2 advanced sections: TopologySection (with tap-to-popup), DiagnosticsSection - Add 3 chart sections: LineChartSection, BarChartSection, PieChartSection - Add utilities: SectionHeader, AiInfoRow, AppDivider - Total: 37 components (16 data sections, 9 legacy cards, 12 basic) ## New AI Commands (15 total) - getSystemInfo, getConnectedDevices, getWifiSettings, getWanStatus - getNetworkOverview, getLanInfo, getDhcpInfo, getEthernetPorts - getFirewallStatus, getPortForwarding, getTimeSettings - getTrafficStats (with history for charts) - getSystemMonitor (CPU/Memory history) - getDeviceAnalytics (device distribution stats) - getWifiStatus (Tx power, bit rate, channel, bandwidth per radio) ## TopologySection Features - Tap-to-show-details popup with MAC, IP, signal, speed - Animation enabled via theme override - Supports extenders and clients with metadata ## Infrastructure - RouterChatController with A2UI v0.9 protocol support - UspCommandProvider reads from L1 dashboard providers - ComponentCatalog with sync tests for registry/prompt alignment - System prompt caching support (~80-90% token savings) Co-Authored-By: Claude Opus 4.5 * fix: update ComponentBuilder import for ui_kit 2.25.0 compatibility Add generative_ui import for ComponentBuilder type which is now exported from gen_ui_contracts instead of ui_kit_library directly. Co-Authored-By: Claude Opus 4.5 * fix(mascot): hide dismiss button for random idle messages Random speech bubbles now use showDismissButton: false so users can only dismiss them by tapping the bubble or waiting for autoHide. Co-Authored-By: Claude Opus 4.5 * feat(mascot): add dynamic message provider with L2 architecture Introduce MascotMessageProvider for generating context-aware random messages: ## Message Categories - **Guidance** (20%): Feature discovery tips (WiFi settings, diagnostics, etc.) - **Status** (50%): Dynamic system state (CPU, memory, devices, mesh, WAN) - **Tips** (30%): Network security and knowledge sharing ## Architecture - `mascot_message_templates.dart`: Template definitions with conditions - `mascot_message_provider.dart`: L2 Provider reading from L1 data providers - Templates use `MascotMessageContext` for dynamic text generation - Conditional templates only show when their condition is met ## Data Sources (L1 Providers) - systemInfoDataProvider: CPU%, Memory%, uptime - devicesDataProvider: online/total count, mesh nodes - wifiDataProvider: radio enabled count - wanDataProvider: connection status ## Extensibility - Add new templates: just add to the corresponding List - Add new category: add enum, create List, update weights - Add new context field: extend MascotMessageContext, read in _buildContext() Co-Authored-By: Claude Opus 4.5 * refactor(cards): integrate layout_blocks primitives with DashboardCardTemplate Merge both design systems: - Keep DashboardCardTemplate as outer wrapper (header/footer/scroll) - Use layout_blocks primitives inside content (LayoutBlock, MetricTile, InfoGrid, etc.) Files updated: 12 dashboard cards across admin, dashboard, devices, firewall, internet_settings, local_network, port_forwarding, topology modules. Co-Authored-By: Claude Opus 4.5 * fix(devices-card): remove nested Expanded in scrollable content DashboardCardTemplate already wraps content in Expanded + ScrollView, so the device list shouldn't add another Expanded + SingleChildScrollView. Co-Authored-By: Claude Opus 4.5 * style: format usp_ethernet_ports_card.dart * fix: address code review findings Critical fixes: - Fix type safety: buildRouterContext now accepts WidgetRef instead of dynamic - Fix password exposure: use full masking ('********') instead of partial Major fixes: - Remove debug prints from TopologySection.build() - Add Semantics wrapper to DashboardCardTemplate footer link for accessibility - Add documentation for intentional Navigator.push usage in mascot animation Tech debt: - Remove unused onViewAll parameter from UspConnectedDevicesCard - Remove unused onViewAll parameter from UspWifiNetworksCard Co-Authored-By: Claude Opus 4.5 * fix(ai): use ProviderReader type for buildRouterContext Changed buildRouterContext to accept a ProviderReader function type instead of WidgetRef, allowing both WidgetRef.read and ProviderContainer.read to be passed. This fixes test compatibility while maintaining type safety. Co-Authored-By: Claude Opus 4.5 * fix(ai): update buildRouterContext call to use ref.read Pass ref.read function to match ProviderReader type signature. Co-Authored-By: Claude Opus 4.5 * refactor(ai): use UI Kit components in router_assistant_view dialogs - Replace Text with AppText in confirmation and config dialogs - Replace TextButton/FilledButton with AppButton.text/AppButton.primary - Add unit tests for routerCommandProviderProvider Co-Authored-By: Claude Opus 4.5 * refactor(ai): replace debugPrint with logger.d for release exclusion Use project logger instead of debugPrint to ensure AI debug logs are excluded from release builds and properly masked for sensitive data. Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 * test(remote): add unit tests for Remote Assistance feature - Add RemoteAssistanceService tests (19 tests) - Add RemoteAccessNotifier tests (20 tests) - Add RemoteAssistanceNotifier tests (18 tests) - Add RemoteClientNotifier tests (22 tests) Also: - Add poll failure tracking with hasPollError state - Add 15s timeout to session validation API call - Extract magic numbers to named constants - Add lint ignore reason comment Co-Authored-By: Claude Opus 4.5 * fix(cloud): remove /cloud prefix from Guardian RA endpoints Guardian API endpoints don't use the /cloud prefix. Co-Authored-By: Claude Opus 4.5 * refactor(ui): use AppSurface and AppText in RemoteSessionChip - Replace Container with AppSurface for theme-aware styling - Replace raw Text with AppText.labelMedium/labelSmall - Use semantic colors (urgency indicated by text/icon color, not background) Co-Authored-By: Claude Opus 4.5 * feat: golden test framework consolidation and HTML report tooling (#925) * docs: add golden test verification report design spec Design spec for automated HTML report generation after golden test verification runs. Covers report structure, failure image comparison, coverage scanning, and self-contained HTML output. Co-Authored-By: Claude Opus 4.6 * docs: add golden test verification report implementation plan Six-task plan covering: test result parser enhancement, coverage scanning, HTML template rewrite, verify script creation, snapshot script simplification, and end-to-end smoke testing. Co-Authored-By: Claude Opus 4.6 * feat: extract failure image paths in test result parser Add extractFailureImages() to parse golden test failure messages and extract expected/actual/diff image paths into a failureImages field. Co-Authored-By: Claude Opus 4.6 * feat: add coverage scanning and --embed flag to combine_results Scans lib/page/*/views/usp_*_view.dart against test/usp_test/page/*/ to calculate golden test coverage. The --embed flag converts failure images to base64 data URIs for self-contained CI artifacts. Co-Authored-By: Claude Opus 4.6 * feat: rewrite HTML report template with modern UI Self-contained HTML with embedded CSS/JS. Includes donut chart, coverage panel, filter bar, collapsible feature groups, and three-way image comparison for failures. Supports dark mode. Co-Authored-By: Claude Opus 4.6 * feat: add run_golden_verify.sh for verification-mode testing Runs golden tests without --update-goldens, parses results, and generates an HTML report with pass/fail stats, failure image comparison, and coverage analysis. Supports --embed for CI artifacts. Co-Authored-By: Claude Opus 4.6 * refactor: remove report generation from snapshot update script Report generation is now handled exclusively by run_golden_verify.sh. The update script focuses solely on regenerating golden baseline files. Co-Authored-By: Claude Opus 4.6 * fix: add FVM detection, remove --tags=loc, handle empty test results - Add FVM detection to run_golden_verify.sh (consistent with run_tests.sh) - Remove --tags=loc since golden tests don't use tag annotations - Use test/usp_test/ directory path for full-mode test targeting - Fix test_result_parser.dart crash on empty results (use fold instead of reduce, handle null suites/result gracefully) Co-Authored-By: Claude Opus 4.6 * fix: update extractInfo regex for new golden test name format The new golden framework produces test names like: "viewName - state - device - locale (variant: macOS)" instead of the legacy format. Add new regex pattern matching first, fall back to legacy format for backwards compatibility. Co-Authored-By: Claude Opus 4.6 * fix: resolve smoke test issues with failure image extraction - Add || true to test_result_parser calls (parser exits 1 on failures, which would halt the script under set -e) - Move extractFailureImages to onDone phase (test metadata like tsName isn't populated during message events) - Add Strategy 2: infer failure image paths from test metadata when Alchemist doesn't include paths in error messages - Fix testCaseFilePath leading slash for relative path resolution Co-Authored-By: Claude Opus 4.6 * fix: prepend ../ to failure image paths for correct report resolution Report lives in snapshots/ subdirectory, so failure image paths (relative to project root) need ../ prefix to resolve correctly when viewing the HTML report in a browser. Co-Authored-By: Claude Opus 4.6 * feat: support --dart-define locale/screen override in golden runner Allow command-line control of which locales and screen sizes to run via --dart-define=locales and --dart-define=screens, without requiring changes to individual test files. Co-Authored-By: Claude Opus 4.6 * chore: remove unused verify_golden_coverage.sh Coverage scanning is already handled by scanCoverage() in combine_results.dart. This script was never referenced by any CI workflow. Co-Authored-By: Claude Opus 4.6 * feat: consolidate golden tests to test/golden_test/ with gallery report - Move golden tests from test/usp_test/ to test/golden_test/ - Add generate_gallery_report.dart for visual golden gallery - Simplify run_generate_loc_snapshots.sh (fvm detection, remove snapshots/ copy) - Update run_golden_verify.sh to output report in test/golden_test/ - Fix combine_results.dart json filter and relative path logic - Fix test_result_parser.dart to write output alongside input Co-Authored-By: Claude Opus 4.6 * feat: add coverage ignore list for views without golden tests Co-Authored-By: Claude Opus 4.6 * feat: add lightbox, comparison view, and thumbnail sizing to gallery report - Lightbox with keyboard navigation (←/→/Esc) and section position indicator - Compare mode: same state side-by-side across locales for quick l10n review - Thumbnail size toggle (S/M/L) for adjustable grid density Co-Authored-By: Claude Opus 4.6 * feat: improve golden framework stability and report UX - Replace naive 5×pump loop with pumpAndSettle + timeout fallback - Add precacheImages config for views with async asset images - Add search, lightbox, overlay slider to verify report - Add search box and Components device grouping to gallery report - Add golden test report usage guide - Update spec to document new settle/precache mechanisms Co-Authored-By: Claude Opus 4.6 * fix: replace hardcoded find.text with locale-independent finders Interaction steps using find.text('English string') fail in non-English locales. Replace with find.byType(Tab).at(index), find.byType(AppButton), find.byIcon, etc. Also fix _resolveDevices to not override custom device configs, and add finder rules to spec. Co-Authored-By: Claude Opus 4.6 * feat: add overflow error detection and reporting to golden tests Collect RenderFlex overflow warnings during golden test execution and write them to goldens/overflow_warnings.json. Both gallery and verify reports now display overflow badges and support overflow-only filtering. Co-Authored-By: Claude Opus 4.6 * feat: enhance report UI with filters, zoom, back-to-top, and fix page heights - Add select all/none toggle for feature, locale, and device filters - Use CSS grid layout for filter groups to prevent overlap - Add fixed back-to-top button (visible after 400px scroll) - Add lightbox zoom with scroll-wheel zoom and drag-to-pan - Increase golden test heights for admin, device_list, dhcp, dashboard, unified_diagnostics, menu, and statistics pages - Update clear_goldens.sh to only delete PNGs and clear overflow artifacts Co-Authored-By: Claude Opus 4.6 * chore: consolidate golden tests under test/golden_test/ and fix doc naming - Move firmware_update tests from test/usp_test/ to test/golden_test/page/ - Update all path references in golden_test_specification.md - Rename golden-test-report-guide.md to golden_test_report_guide.md Co-Authored-By: Claude Opus 4.6 * feat: replace checkbox filters with chip-style toggles in report UI Co-Authored-By: Claude Opus 4.6 * style: format golden_runner.dart Co-Authored-By: Claude Opus 4.6 * fix: propagate test failure exit code in run_golden_verify.sh Co-Authored-By: Claude Opus 4.6 * fix: remove set -e to ensure verify report is always generated Co-Authored-By: Claude Opus 4.6 * fix: replace hardcoded find.text with locale-independent finders in wifi_settings Co-Authored-By: Claude Opus 4.6 * Fix format --------- Co-authored-by: Claude Opus 4.6 * refactor(cloud): replace LinksysCloudRepository with GuardianApiClient - Create GuardianApiClient as single point for Guardian API calls - Remove LinksysCloudRepository (only Remote Assistance was using it) - Delete 7 unused service files (asset, auth, device, event, ping, smart_device, user) - Delete 10 unused model files (cloud_account, cloud_phone, etc.) - Clean up ~30 unused constants from cloud_const.dart - Update RemoteAssistanceService to use GuardianApiClient - Update tests to mock GuardianApiClient Files removed: 18 Lines removed: ~2500 Co-Authored-By: Claude Opus 4.5 * feat(connection): auto-detect unexpected disconnection and enter wait-for-recovery (#932) * feat(connection): auto-detect unexpected disconnection and enter wait-for-recovery When SSE reconnection fails 2 consecutive times (indicating the device has likely moved out of router range), automatically enter the wait-for-recovery flow instead of waiting for all 5 retries to exhaust. Recovery probe takes over with lightweight health checks every 10s, and shows a modal dialog informing the user of the disconnection. Co-Authored-By: Claude Opus 4.6 * fix(connection): skip redundant enterWaiting when shell shows natural recovery dialog The auto-detect path already transitions to waitingForRecovery via _onSseReconnectFailed; the shell listener only needs to display the dialog without re-entering the state. Co-Authored-By: Claude Opus 4.6 * chore(skill): use fvm dart format in review-pr-readiness skill Co-Authored-By: Claude Opus 4.6 * fix(connection): address code review findings from PR #932 - Add reentrancy guard in _scheduleReconnect after onReconnectFailed callback to prevent timer/state mutation after intentional disconnect - Add state check + try/finally in _showNaturalRecoveryDialog to prevent stuck dialog on race condition or exception - Clear _recoveryContext on recovered, serialMismatch, and exitToLogout so the public getter accurately reflects "null when not in recovery" Co-Authored-By: Claude Opus 4.6 * fix(connection): update recovery_dialog_helper import path after merge The file was moved from lib/core/connection/helpers/ to lib/page/_shared/helpers/ in dev-2.5.0; update the import in usp_dashboard_shell.dart accordingly. Co-Authored-By: Claude Opus 4.6 * refactor(connection): address PR review suggestions - Move state check before setting _recoveryDialogShowing flag for more intuitive flow in _showNaturalRecoveryDialog - Add comment explaining why threshold is 2 (avoids ~6 min wait) - Add tests verifying recoveryContext is cleared after recovered and serialMismatch probe results Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 * fix(remote): address code review findings - H2: Add HTTP status validation in GuardianApiClient._request() - M2: Capture all refs before async gap in RemoteSessionChip._disconnect() - M4: Switch domainBase from linksysDomain to guardianDomain M3 analyzed: Duplicate timers are intentional - confirm_view timer is for pre-connection validation, remote_access_provider timer is for post-connection session tracking. Different lifecycle stages require separate management. Co-Authored-By: Claude Opus 4.5 * feat(remote): add client-side Remote Assistance with session recovery - Add RemoteAssistanceBanner for PENDING state after page refresh - Add RemoteAssistanceActiveDialog for ACTIVE state recovery - Add deviceCredentialsProvider to unify credential access - Integrate session recovery in DashboardShell via orchestrator - Add checkAndRestoreSession() to RemoteClientNotifier - Fix ServiceError handling per constitution Article XIII - Use showAppDialog instead of showDialog per UI Kit guidelines - Consolidate test data to test/mocks/test_data/ - Add golden tests for banner (pending, pending_urgent states) - Add golden tests for dialog (initiate, pending, active, invalid states) Co-Authored-By: Claude Opus 4.5 * feat(mascot): health dashboard with problem-first display (#939) * feat(mascot): add health dashboard with problem-first display and DRY refactor - Replace word cloud with HealthStatusView showing problem-first display - Add DimensionDetailView for expanded dimension actions - Add healthEvaluationContextProvider to eliminate 4x code duplication - Migrate all Text widgets to AppText per constitution Article XV - Add brand color comments for mascot hardcoded colors - Delete unused health_word_cloud.dart - Fix demo mode to directly enter Dashboard for UI verification Co-Authored-By: Claude Opus 4.5 * fix(ui): enable icon support in NetworkBadgeWidget Pass NetworkBadge.icon to AppBadge now that ui_kit_library v2.25.0 supports the icon parameter. Closes #916 Co-Authored-By: Claude Opus 4.5 * fix(mascot): address code review feedback for health dashboard - #2: Migrate Text to AppText in health_dialog_provider - #3: Remove BuildContext from provider, use onNavigate callback (IoC) - #1: Replace _Epoch hack with nullable DateTime? lastEvaluated - #4: Add previousDisabledRadios state tracking to WiFi trigger - #6: Simplify Registry to static HealthDimensions class - #8: Update documentation (Word Cloud → Health Status View) Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 * feat(internet-settings): add MTU validation and MAC Clone editing (#757, #843) - Update codegen for FW 1.2.1 alias changes (cpe-wan/cpe-lan → wan/lan) - Add MTU range validation (576-1500, PPPoE: 576-1492) with error messages - Add MAC Clone input field with Clone button to select connected devices - Integrate WanMacClone.update() for writing Ethernet.Link.MACAddress - Invalidate wanDataProvider after save to sync Dashboard - Update WiFi guest detection to use alias suffix instead of index - Fix test mocks for new alias values and Ethernet.Link data Co-Authored-By: Claude Opus 4.5 * fix(auth): display meaningful error message on login failure (#940) Previously, USP login failures showed "Unknown error: _ErrorUnexpected" because error details were lost in the coordinator-to-provider chain. Changes: - UspAuthCoordinator.tryUspLogin() now throws typed ServiceError instead of returning boolean, preserving error context - AuthNotifier maps ServiceError to UnexpectedError with proper error codes for View layer consumption - login_local_view handles cases where no delay/attempts data is present - Added errorInvalidAdminPassword to error_code_helper for proper i18n Note: Account locked vs invalid password distinction requires WASM client fix (linksys/usp_framework#34) - currently both show as incorrect password. Co-Authored-By: Claude Opus 4.5 * feat(internet-settings): enable PPPoE Service Name field Re-enable pppoeServiceName editing that was previously disabled due to bbfdm fault 9001. The firmware issue has been resolved. Co-Authored-By: Claude Opus 4.5 * Revert "feat(internet-settings): enable PPPoE Service Name field" This reverts commit ef0650d749aceb13f21dc085ccedef9c0aa6f8fe. * revert: disable PPPoE Service Name and MAC Clone features - Revert PPPoE Service Name field (FW still rejects SET with fault 9001) - Remove MAC Clone UI and service integration (WanMacClone.g.dart) - Keep MTU validation (576-1500/1492) and Dashboard sync fix Co-Authored-By: Claude Opus 4.5 * test: update validator tests for MTU range enforcement - Add valid mtu value (1500) to all test forms - Remove mtu=0 (auto) test since auto mode is hidden - Add PPPoE-specific MTU max (1492) tests Co-Authored-By: Claude Opus 4.5 * chore(wasm): update usp_client to return full error messages WASM client now correctly returns complete error messages from usp-auth-cgi, enabling meaningful login error display (e.g., "Too many failed attempts" for account locked). Ref: #940 Co-Authored-By: Claude Opus 4.5 * chore(dashboard): disable Speed Test card Speed Test feature blocked by FW support (#857). Comment out: - Widget spec definition - Factory registration - Import Route preserved for future re-enablement. Co-Authored-By: Claude Opus 4.5 * test: update dashboard tests for Speed Test removal Update widget count expectations from 19 to 18 after disabling Speed Test card (#857). Co-Authored-By: Claude Opus 4.5 * feat(internet-settings): add 6rd tunnel field validation with error messages - Add IPv6 CIDR format validation for 6rd prefix using IPv6WithReservedRule - Add IPv4 validation for border relay using IpAddressRule - Add inline error messages via externalErrorText - Rejects reserved addresses: loopback, multicast, unspecified Closes #852 Co-Authored-By: Claude Opus 4.5 * fix(internet-settings): skip MTU validation for bridge mode Bridge mode uses auto MTU (mtu=0 sent to firmware). The validator was blocking save because 0 < 576. Now bridge mode: - Skips MTU validation (allows mtu=0) - Shows "Auto" in UI instead of editable field Co-Authored-By: Claude Opus 4.5 * refactor(errors): preserve diagnostic code/detail through ServiceError Error info (fault code + raw message) was lost when errors converged into ServiceError, on both conversion paths: - Path 1 (mapUspErrorToServiceError): empty-constructor subtypes like ResourceNotFoundError/UnauthorizedError dropped both code and message. - Path 2 (UspResultParser -> Usp*FailureError): kept only joined summary + failedPaths, discarding per-path errorCode/errorMessage. Changes: - ServiceError base class gains optional `code` (int?) and `detail` (String?) diagnostic fields; all subtypes accept them via super (except ServiceSideEffectError, a success-with-side-effect type). - Remove the per-subtype `message` field from InvalidInputError/NetworkError/ ConnectivityError/UnexpectedError/ServiceNotInitializedError and unify on the base `detail`; toString() now reads `detail`. InvalidInputError keeps `field`, UnexpectedError keeps `originalError`. - mapUspErrorToServiceError now passes code (faultCode/httpStatus) + detail into every mapped ServiceError. - UspCompleteFailureError/UspPartialFailureError store the full List failures (path + code + message); `failedPaths` becomes a derived getter for backward compatibility. - Mechanical caller updates across 14 services + tests: message: -> detail:, failedPaths: -> failures:, and .message reads -> .detail (login flow, diagnostics notifiers). * feat(l10n): centralize error message localization for USP features All USP requests (Get/Set/Add/Operate) across feature pages now flow through a unified error handling pipeline: - Path 1 (fetch): errors stored in `state.error`, displayed via `ServiceErrorView` - Path 2 (save): errors rethrown to View, displayed via snackbar with `localizeServiceError` Key changes: - Add `ServiceError.code` and `ServiceError.detail` for diagnostic context - Add `TimeoutError` subtype for timeout handling - Remove unused OTP/admin-password ServiceError subtypes - Add `localizeServiceError()` central mapper with exhaustive switch - Add `ServiceErrorView` shared widget replacing per-feature `_buildError()` - Add 12 ARB keys for error messages - Change all feature state models: `String? errorMessage` -> `ServiceError? error` - Update providers to pass through ServiceError objects (not stringify) - Update views to use shared components Coverage: all USP feature pages except firmware_update (deferred). Note: SSE subscription errors are out of scope for this change. Note: UnexpectedError surfaces raw `detail` string as final fallback. * fix(remote): add session recovery guard to UspDashboardShell - Create RemoteAssistanceSessionGuard widget for client-side session recovery - Integrate guard into UspDashboardShell (the actual shell used in routing) - Add RemoteAssistanceBanner to UspDashboardShell for PENDING state - Shows blocking dialog when ACTIVE session exists after page refresh The previous implementation incorrectly added recovery logic to DashboardShell, but the app actually uses UspDashboardShell for routing. Co-Authored-By: Claude Opus 4.5 * fix(remote): force end CA session on 401 unauthorized When the CA's session polling receives a 401, automatically: - Mark session as invalid (triggers UI state change) - Stop polling/countdown timers - Navigate to confirmation page with expired=true param - Logout the CA user This ensures the CA UI doesn't hang when the session token expires. Co-Authored-By: Claude Opus 4.5 * fix(remote): handle INVALID status in client RA dialogs When the session becomes INVALID (CA ended it or session expired): - _RemoteAssistanceDialog: Shows snackbar notification - RemoteAssistanceActiveDialog: Shows snackbar and auto-closes Also handles edge case where dialog opens with already-invalid status by showing appropriate UI with close button. Co-Authored-By: Claude Opus 4.5 * chore(usp): update usp_client WASM to 0.11.1 Update vendored WASM client artifacts from usp_framework: - usp_client.js: 0.9.0 -> 0.11.1 - usp_client_bg.wasm: 0.9.0 -> 0.11.1 Co-Authored-By: Claude Opus 4.5 * chore: remove unused DashboardShell DashboardShell is not referenced by any code — only UspDashboardShell is used in routing. Remove to avoid confusion. Co-Authored-By: Claude Opus 4.5 * fix(internet-settings): use SET instead of ADD/DELETE for VLAN tagging and enable PPPoE ServiceName (#951) - Replace VLAN lifecycle (ADD/DELETE) with SET Enable on the existing VLANTermination.1 instance, fixing the bug where disabling VLAN still showed as enabled due to a system-default instance that cannot be deleted. - Uncomment PPPoE ServiceName field in UI and service layer now that bbfdm supports SET on PPPoE.ServiceName. Co-Authored-By: Claude Opus 4.6 * fix(internet-settings): clean up stale comments after VLAN lifecycle removal - Fix step numbering gap (Step 4 → Step 6 becomes Step 4 → Step 5) - Remove orphaned VLAN Lifecycle section header - Remove leftover DELETE doc comment on _handleOperateResult Co-Authored-By: Claude Opus 4.6 * chore(.claude): fix dart format hook by removing unmatched if glob The `if: "Bash(git commit *)"` glob cannot match heredoc-style commit commands. Remove the condition since the hook command itself already guards with `if [ -n "$staged" ]`. Co-Authored-By: Claude Opus 4.6 * test(internet-settings): align VLAN tests with SET-based approach Replace ADD/DELETE VLAN lifecycle tests with SET Enable tests: - internet_settings_service: test enable/disable via SET, skip when no instance - pnp_service: test enable/disable VLAN via SET on existing instance - Remove obsolete DELETE failure test Co-Authored-By: Claude Opus 4.6 * feat(skill): add mandatory test execution to review-pr-readiness Static checks (format, analyze, file existence) cannot catch logic regressions. Add Step 4.3 that collects and runs all affected tests before issuing the gate stamp — test failures now block PR creation. Co-Authored-By: Claude Opus 4.6 * fix(remote): use abs() for expiredIn forward compatibility Cloud API will change expiredIn from negative to positive format. Using abs() ensures both conventions work during transition: - Current: negative value = remaining seconds - Future: positive value = remaining seconds Co-Authored-By: Claude Opus 4.5 * fix(l10n): align batch fault-code localization with _mapProtocolError The batch localizer (_localizeBatch) only recognized the five 7xxx codes exposed by UspErrorDetail's helpers, so write failures carrying bbfdm vendor codes (9001/9005/9007/9008) or the WASM transport code (9999) all fell through to the generic errorUnexpected message. The fetch path (_mapProtocolError) already mapped these, so the same firmware code localized differently depending on which path produced it. Extract _localizeFaultCode(code) and switch on the raw errorCode, mirroring _mapProtocolError's table: - 7004/7005/7006/9008 -> errorInvalidInput - 7026/7027/9005/9007 -> errorResourceNotFound - 9001 -> errorUnauthorized - 9999 -> errorNetwork (never reached the router) Most user-visible win: a 9999 (no connection to the router) now reads "Network error. Please check your connection." instead of the vague "Something went wrong." UspErrorDetail's helpers are left untouched (still used by the test console). Unknown vendor codes still fall back to the generic message and deliberately do not surface raw firmware text. * fix(auth): restore account-locked message after AdminAccountLockedError removal Removing the AdminAccountLockedError subtype severed the lockout-message chain: the coordinator threw UnexpectedError(detail: 'Account locked') — a free-form string that does not equal the errorAdminAccountLocked constant ('ErrorAdminAccountLocked') — and _mapToViewError no longer had an account-locked branch, so it fell into the generic ServiceError arm and overwrote detail with errorUnexpected. The login view then resolved '_ErrorUnexpected' to unknownHandle, so a locked-out user saw "Something went wrong" instead of the too-many-attempts / account-locked message. A security-relevant lockout signal was swallowed. Fix: - Coordinator throws UnexpectedError(detail: errorAdminAccountLocked) — the actual error-code identifier the view's errorCodeHelper recognizes. - _mapToViewError passes such an UnexpectedError through unchanged instead of overwriting it to errorUnexpected. Tests: - usp_auth_coordinator_test: tryUspLogin maps account-locked WASM error to UnexpectedError(detail: errorAdminAccountLocked); plus invalid-credentials and authenticated=false cases. - auth_notifier_test: localLogin keeps errorAdminAccountLocked through _mapToViewError (regression guard). * fix(remote): address PR #955 review issues Critical fixes: - Move RemoteAssistanceService to lib/core/cloud/services/ (fix core→page dependency) - Fix error-mapping: _validateResponse now throws ErrorResponse for proper 401 handling - Remove PIN plaintext logging (security) - PENDING close now revokes server session (PIN exists server-side) - Add mutation lock to UspClient swap in activate()/deactivate() Warning fixes: - Strip token from URL after reading (prevent history/Referer leakage) - deactivate() now disposes and unregisters UspClient - Add null/empty validation for device token and createPin responses Co-Authored-By: Claude Opus 4.5 * test(l10n): cover service_error_localizations and ServiceErrorView Both files shipped with zero test references. Add unit/widget coverage for the error-display logic, asserting the TYPE/CODE → l10n-key mapping (compared against loc(ctx).errorXxx, not hardcoded English) so the tests survive copy changes but break on mapping drift. service_error_localizations_test: - every sealed subtype → its l10n string (incl. infra types → errorUnexpected) - UnexpectedError surfaces detail when present, else fallback - non-ServiceError input → errorUnexpected - batch _localizeFaultCode per-code branches: 7004/7005/7006/9008 → invalidInput, 7026/7027/9005/9007 → resourceNotFound, 9001 → unauthorized, 9999 → network, unknown vendor code → unexpected (guards the no-raw-text-leak rule) - empty failures → unexpected; first-failure selection; partial-failure path service_error_view_test: - renders title + localized detail + retry when error is set - hides the detail line when error is null - invokes onRetry on tap * style: dart format the three files flagged by CI Apply dart format to the files the CI format check reported (whitespace / line-wrapping only, no logic changes; affected tests still pass). Verified the whole repo is now format-clean: dart format --set-exit-if-changed passes (1037 files, 0 changed). * docs: fix stale lifecycle comments in usp_internet_settings_service After #951 moved VLAN tagging from Add/Delete to SET on an existing instance, the class doc and InternetSettingsFetchResult field doc still described the old "PPP/VLAN multi-instance lifecycle (Add/Delete)". Update them to reflect the current behavior: PPP instance lifecycle still uses Add; VLAN enable/disable is a SET on the existing instance. Comment-only; no logic change. * fix(polling): start timer when dashboardDomainReadyProvider already resolved ref.listen() only fires on state changes — if dashboardDomainReadyProvider completed before the polling provider was first read, the listener never fires and the timer never starts. This caused Dashboard traffic cards and Statistics page to show "Waiting for data..." indefinitely. Add immediate state check in build() to start timer if conditions are already met. Co-Authored-By: Claude Opus 4.5 * fix(polling): unify auth guard logic and add test coverage for already-resolved path - Extract _startTimerIfAuthenticated() helper to unify auth check between ref.listen and ref.read fallback paths (addresses Hank's review comment) - Add tests for when dashboardDomainReadyProvider already resolved before first provider read — covers the new microtask startup path - Add tests to verify timer does NOT start when domain ready but logged out Co-Authored-By: Claude Opus 4.5 * fix(remote): address Hank's review on remote_client_provider 1. Add ref.onDispose() to clean up timers/subscriptions on rebuild 2. Replace _creds getter with _credsOrNull — graceful early return instead of throwing StateError in Timer callbacks 3. Fix poll loop condition: check status != INVALID and expiredIn <= 0 instead of expiredIn.abs() > 0 (which never terminated) 4. Only start countdown timer if not already running (avoid rebuild on every poll tick) 5. Fix ServiceError API: use 'detail' instead of 'message' (dev-2.5.0) Co-Authored-By: Claude Opus 4.5 * fix(remote): use ServiceError.detail instead of .message in view layer After merging with dev-2.5.0, the ServiceError API changed from message to detail. This caused compilation errors in pattern matching. Also fix test setup for "not authenticated" tests to use LoggedOutNotifier instead of AlwaysAuthenticatedNotifier, avoiding unintended USP calls from auto-timer triggered by dashboardDomainReadyProvider. Co-Authored-By: Claude Opus 4.5 * feat(wan): implement PPTP/L2TP connection type support (#839) - Add GRE Tunnel and L2TP Tunnel codegen definitions (fetch/update) - Extend UspWanConnectionType with pptp/l2tp variants and detection logic - Add PPP LowerLayers field to codegen for tunnel type selection - Implement save orchestration: PPP lifecycle → LowerLayers → tunnel RemoteEndpoints → WAN mode switch → PPP credentials - Add server address field and PPTP/L2TP-specific UI in IPv4 section - Add form validation for server address when PPTP/L2TP selected - Update connection status banner to handle PPP-based tunnel types - Cover new logic with unit tests (model, validator, service) Co-Authored-By: Claude Opus 4.6 * feat(l10n): localize hardcoded strings across USP pages (#960) * feat(l10n): localize hardcoded strings across USP pages Replace hardcoded English strings with loc(context).xxx calls: - Add 717 new ARB keys (1018 → 1735 total) - Add 1041 new loc() calls across 82 view files - No keys deleted or modified (only additions) - All changes in View layer only (no provider/service changes) Covers: statistics, diagnostics, dashboard, wifi_settings, port_forwarding, devices, dhcp, firewall, dmz, static_routing, ipv6_port_service, instant_privacy, instant_safety, ai_assistant, firmware_update, admin, menu * refactor(l10n): deduplicate and consolidate ARB keys - Remove 12 duplicate keys (same key appearing twice in app_en.arb) - Consolidate 60 pairs of different keys with identical/similar values - Rename keys across all 26 locale files to preserve translations - Delete unused dead-code keys (vpn*, modal*, pnp* prefixes) - Fix avgValue key that was accidentally deleted * fix(auth): reduce duplicate login requests on app startup - Add Completer deduplication to restoreSession() and authProvider.init() to coalesce concurrent calls - Add cooldown mechanism after failed login to prevent account lockout - Implement token-first strategy: try refreshToken() before password login - Remove redundant restoreSession() calls from wifi_settings_provider and internet_settings_notifier (auth already handled by orchestrator) - Remove unnecessary autoConfigurationLogic() call on /localLoginPassword route (user is already on login page) This reduces login requests from 5 to 1 on app startup with stored credentials, preventing potential account lockout from rapid retries. Co-Authored-By: Claude Opus 4.5 * fix(pnp): prevent guest WiFi page from appearing twice (#963) Root cause: PnpEntryView's ref.listen triggered navigation to /pnp/config when save failed and state reverted to WizardConfiguring, causing the view to recreate and reset _currentStep to 0. Changes: - Add guard in pnp_entry_view.dart to only navigate on initial transition - Update login_local_view.dart to route via '/' for proper PnP check - Refactor PnP UI with AppCard + LayoutBlock for consistent styling - Add split SSID mode support (per-band WiFi config) for #935 alignment - New PnpWifiBand model for per-band configuration - PnpWifiConfig extended with mainBands/guestBands lists - Service layer handles both unified and split mode save - View renders per-band forms when split mode detected - WiFi Ready page shows all band credentials in split mode Co-Authored-By: Claude Opus 4.5 * fix(auth): address review findings for login deduplication PR - Restore autoConfigurationLogic + redirectLogic on /localLoginPassword route to prevent logged-in users from staying on login page - Fix dead code in AuthNotifier.init(): AsyncValue.guard never throws, so catch block was unreachable; now properly checks AsyncError - Add test coverage for restoreSession coalescing and cooldown - Add test coverage for AuthNotifier.init() coalescing Co-Authored-By: Claude Opus 4.5 * fix(pnp): address review findings for guest WiFi duplicate PR - C1: Add missing meshNodes to updateGuestSsid (prevents mesh regression) - C2: Revert login navigation to goNamed(uspDashboard) to avoid rebuild loops (context.go('/') requires #976 coalescing which isn't merged) - W1: Add empty guard in _buildCompleteSplitMode to prevent crash - W5: Add mainSsids empty check in fetchWifiConfig to prevent crash Co-Authored-By: Claude Opus 4.5 * fix(pnp): restore context.go('/') for proper PnP check + address Round 2 review Round 2 fixes: - C1: Restore context.go('/') to enable PnP check via router redirect (requires #976 auth coalescing to prevent rebuild loops) - C2: Use mapUspErrorToServiceError for mainSsids empty check - W2: Add logger.w for unexpected empty mainBands fallback Depends on: #976 (auth coalescing) — merge #976 first to prevent rebuild loops from repeated init() calls. Co-Authored-By: Claude Opus 4.5 * refactor(auth): use Dart 3 pattern matching for AsyncError check Replace redundant `as AsyncError` cast with pattern matching: `if (state case AsyncError(:final error, :final stackTrace))` Co-Authored-By: Claude Opus 4.5 * fix(auth): propagate error to first caller and add completeError test (W3) - First caller now also throws when init() fails (instead of returning null) - Add test for concurrent init waiters receiving error via completeError - Fix dart format issue from previous commit Co-Authored-By: Claude Opus 4.5 * fix(auth): restore "init never throws" contract per review feedback Remove Error.throwWithStackTrace — callers (app.dart, router_provider.dart) use bare .then() without .catchError, so throwing would leave the splash screen stuck or break the redirect. Primary caller now receives null on error; concurrent waiters still receive error via completeError. Co-Authored-By: Claude Opus 4.5 * l10n: complete missing translations, migrate hardcoded strings, fix existing bugs (#979) * chore(l10n): clean up unused/orphan keys and add keys for hardcoded strings Clean up the ARB files and prepare l10n keys before the code-side hardcoded-string migration: - Remove 512 unused keys (verified no Dart references via flutter analyze; kept avgValue/copyRight which are accessed via cross-line loc() calls) - Remove 901 orphan entries (60 keys present in locale files but absent from app_en.arb) - Add 111 new keys to app_en.arb for soon-to-be-migrated hardcoded strings (placeholder metadata included) - Drop orphan key meshBackhaulStep; reuse existing meshBackhaul in step_result_tile - Add tools/check_l10n.dart l10n health-check tool Note: the 111 new keys show as "unused" until the code migration replaces the hardcoded strings with loc(context) calls (next step). * refactor(l10n): migrate hardcoded strings to loc() in View files Replace 189 hardcoded UI strings across 62 View/widget files with loc(context) calls, using the keys added in the previous commit: - ~100 sites reuse existing keys (case/whitespace-normalized) - ~89 sites use the newly added keys (incl. placeholder forms) - Drop `const` where a loc() call is now in a previously-const widget * chore(l10n): drop unused keys reserved for non-View files The 7 non-View files (mascot dialog providers, pdf service, menu_badge, ethernet service, app_utils, package builders) are deferred: engineers decide per-string from the hardcoded-strings report, since many are non-display label values or cannot be localized in place. - Remove the 21 keys (+3 placeholder metadata) added only for those files - Whitelist addedWidgetNamed/nOnlineOfTotal/nRadios in check_l10n.dart (in use, but via cross-line loc() calls the single-line regex misses), and clarify the whitelist's strict criteria in a comment Result: Unused Keys = 0, flutter analyze = 0 errors. * feat(l10n): translate all missing strings across 25 locales Fill in every missing translation so all 25 locale ARBs now have the same 1072 keys as app_en.arb (17,664 entries added). - Translations generated per-locale, matching each file's existing tone and terminology - All {placeholders} preserved verbatim (validated programmatically); technical acronyms (WiFi/DNS/IP/MAC/DHCP/VPN/...) kept in Latin script - Only missing keys added; existing translations untouched Result: Missing Translations = 0 (was 16,189), flutter analyze = 0 errors. Remaining Duplicate Values are pre-existing synonym keys in app_en.arb (e.g. auto/automatic, firmwareUpdate/updateFirmware) — out of scope here. * fix(l10n): correct semantic issues found in translation review Reviewed ar/ja/de/tr/ru/th against English; fixed the confirmed errors: - ar diagnosticsRecBottleneckDesc: "hop" was نقطة وثوب (meaningless); use وثبة, consistent with hops/nHops - ja radios/nRadios/radioBand/noWifiRadiosAvailable/wifiChannelsSubtitle: 電波 (radio waves/signal) → 無線 (radio units), the intended meaning - tr instantPrivacyPageDesc: "yeni hiçbir cihaz engellenecek" was ungrammatical (negative determiner + affirmative verb) → "yeni cihazlar" - de main: "Haupt" (fragment) → "Hauptnetzwerk" (main WiFi network, paired with guest in the UI) ru and th reviewed as excellent — no changes. Placeholders intact, flutter analyze = 0 errors, Missing Translations still 0. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(l10n): repair HTML entities and location content-bleed in existing translations Full-file semantic review (zh/zh_TW/es/fr/pt/id/ko, all 1072 keys) surfaced pre-existing bugs in legacy translations. Mechanical fixes applied across all 25 locales: - Unescape 19 corrupted HTML entities (ü/á/Σ/"/... → real characters); French   correctly preserved as U+00A0 - Fix `location`: 16 locales had a whole privacy-disclaimer paragraph instead of the single-word "Location" section-header label flutter analyze = 0 errors. Semantic mistranslations (wrong content, wrong terms) tracked separately. * fix(l10n): correct semantic mistranslations in existing translations Confirmed content/term errors found during full-file review of the 7 most widely-used locales (verified against English, not length heuristics): - zh/zh_TW menuRestartNetworkLastMessage: was a short "restart? confirm" string → full multi-node mesh-restart message - zh_TW factoryResetDesc: wrong content → label-default reset description - zh_TW factoryResetChildTitle/rebootChildTitle/menuRestartNetworkChildMessage: were Simplified Chinese pasted into the Traditional file → converted to zh_TW - zh timezoneArmenia: "美国" (United States) → 亚美尼亚 (Armenia) - pt routeName roteador→rota; editDhcpReservation Adicionar→Editar; releaseAndRenew Desbloquear→Liberar; upToDate Atualização→Atualizado - es routeName router→ruta; macAddress plural→singular; timezoneGreeceUkraineRomania dropped extra "Turquía" - fr timezoneTurkeyIraqJordanKuwait added missing "Irak"; pnpFwUpdateDesc wrong content → download/reboot wait message - fi/es_ar timezone separator fixes flutter analyze = 0 errors, Missing Translations still 0. * test(l10n): provide localization delegates for widget tests using loc() The hardcoded-string migration made several widgets call loc(context), which requires AppLocalizations in the widget tree. Update the affected tests so the test harness provides it: - usp_time_settings_card / usp_timezone_card: add localizationsDelegates + supportedLocales to the test MaterialApp wrappers - 6 mascot health dimension tests: wrap the getActions() context in a localized MaterialApp instead of a bare SizedBox - usp_time_settings_card: fix two stale assertions that searched for "Daylight Savings Time" — the card renders the "DST" label (pre-existing mismatch, surfaced now that the widget builds with localization) All 93 affected tests pass. * chore(l10n): tighten hardcoded-string detector to near-zero false positives Prioritize precision over recall so the report flags only genuine hardcoded strings; rare cross-line misses are left to manual review. - Add high-confidence same-line params (detailLabel/message/tooltip) and human-phrase variable assignments (e.g. `... ? 'View all' : 'View details'`) - Drop false positives: pure interpolation ('$e'), cross-line Semantics a11y labels, example/token literals, and the over-broad look-ahead that hit const data tables - Exclude lib/page/test_console/ (USP Console — dev-only) and add a reviewer note that flagged strings mix real UI copy with English-kept acronyms * feat(l10n): localize cross-line hardcoded strings missed by the scanner After tightening the detector, a manual sweep recovered display strings the old single-line scanner had missed (cross-line widget calls, variable-stored labels). Migrated the View-side ones: - 8 View files, 13 sites → loc(): View all (×3), Apps, UTC Offset, Copy PIN, End Session, mascot toolbar tooltips (Print Report/Theme Studio/FAQ/AI Assistant), and two empty-state messages - Reuse existing apps/endSession; add 9 new keys, translated across all 25 locales (Missing Translations stays 0) - Add localization delegates to mascot_toolbar_test so loc() resolves Deferred (unchanged): the two mascot dialog providers, whose labels live in const MascotDialogOption(...) and need a small refactor to localize. flutter analyze = 0 errors; affected tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(l10n): localize remaining dashboard card strings Second manual dashboard sweep found strings the scanner can't see (cross-line, variable-stored, model-returned). Localized them, keeping BuildContext in the View layer only: - View strings: Renew Lease/Renewing, LAN Connected, Connected/Disconnected, empty-states, "Online - DHCP" (status localized, device type kept) - Card footer: View details / View all + nItems (ICU plural) - Tier/status via View-side enum resolvers (no context in model/service): HealthTier/SignalTier.resolveLabel; firewall legend localizes Accept/Drop/ Other for display while keeping the raw value as map key; time badge localizes synchronized only - Removed now-unused HealthScore.tierLabel Reuse-first: 12 new keys (incl. nItems plural), reusing existing tier/status keys. Translated across 25 locales with correct CLDR plural categories. flutter analyze = 0; Missing/Unused/Orphan = 0; affected tests pass. * feat(l10n): localize Auto/None/Mixed in WiFi settings dialogs WiFi security/mode/channel-width/channel dialogs and the WiFi status card displayed raw device values. Localize the plain-UI-word ones for DISPLAY only via a shared View helper wifiDisplayValue() in wifi_ui.dart: - Auto / None / Mixed → localized (reuse existing auto/none/mixed keys) - Technical tokens kept verbatim: WPA2/WPA3-Personal/OWE, 20MHz/40MHz/..., 802.11.../Only, channel numbers - Option values / map keys stay as the raw device value; only titles/labels are translated No new keys. flutter analyze = 0; Missing/Unused/Orphan = 0; wifi tests pass. * fix(l10n): correct "Main" WiFi network label mistranslation In the WiFi settings cards, "Main" (the main network, paired with "Guest") was mistranslated: - zh_TW: 主頁面 (home page) → 主要 - zh: 主界面 → 主要 - zh_TW guest: 來賓 → 訪客 (more common UI term) - th guest: was left untranslated ("Guest") → แขก Other 23 locales already had correct "Main" wording (Principal, メイン, etc.). * chore(l10n): remove dead variable-assignment branch in hardcoded detector The variable-assignment relaxation was a no-op: the three same-line patterns (Text()/AppText()/named-param) never match a bare RHS string assignment, so the branch changed neither precision nor recall. Removed it and replaced with an honest NOTE documenting that variable-stored display strings are a known recall gap, caught by manual review. --------- Co-authored-by: Claude Opus 4.8 (1M context) * fix(remote): update expiredIn logic from negative to positive time remaining (#982) * fix(remote): update expiredIn logic from negative to positive time remaining The Guardian API changed expiredIn semantics: positive values now indicate time remaining (was negative). This caused _pollSessionStatus to exit immediately without polling, preventing PENDING → ACTIVE transition detection. Closes #981 Co-Authored-By: Claude Opus 4.5 * fix(remote): update expiredIn test data to positive values Update remote_access_provider_test.dart to use positive expiredIn values as the default, matching the current API convention. The .abs() handling remains as defensive code to support both conventions. Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 * fix(ethernet): show LAN port as disconnected when no wired devices - LAN port isUp now reflects actual wired device connections, not interface status (which is always 'Up' for aggregated switch) - Remove inaccurate LAN port count denominator from display since physical port count is unavailable via TR-181 Co-Authored-By: Claude Opus 4.5 * fix(pnp): use correct SSID on reconnect screen in split mode In split mode, `wifiConfig.ssid` and `wifiConfig.password` are unified-mode fields that are never updated by `updateMainBandSsid/Password`. The reconnect screen was showing stale values from these fields. Added `reconnectSsid` and `reconnectPassword` getters to `PnpWifiConfig`: - In split mode: returns the first dirty band's credentials - In unified mode: returns the existing `ssid`/`password` fields This fixes the bug where Du ISP router users would see the old WiFi name on the reconnect screen after editing band-specific SSIDs. Co-Authored-By: Claude Opus 4.5 * fix(wan): reorder WAN save to set mode before tunnel writes (#839) Per Architecture issue #119, the firmware requires AddressingType=IPCP to be set first so it can sync proto=pptp/l2tp before the GRE/L2TPv2 tunnel RemoteEndpoints is written. Move the WAN mode switch ahead of the PPP LowerLayers and tunnel RemoteEndpoints steps. Note: the M60-EU firmware 1.2.1.26061108 does not yet materialize the GRE/L2TPv2 tunnel instance for a pptp/l2tp WAN (tracked in #839/#840, blocked:fw-support), so PPTP/L2TP save still returns 7016 until the firmware carries the issue #119 netmngr changes. This reorder is the correct app-side sequence and does not affect DHCP/Static/Bridge/PPPoE. Co-Authored-By: Claude Opus 4.6 * fix(test): remove stale @override methods from fake notifiers toggleWifiRadio and updateWifiRadioChannel no longer exist in WifiDataNotifier interface, causing override_on_non_overriding_member warnings in CI. Co-Authored-By: Claude Opus 4.5 * fix(test): remove unused embedImages variable Fixes unused_local_variable warning in CI. Co-Authored-By: Claude Opus 4.5 * ci: add flutter gen-l10n step before analyze The lib/l10n/gen/ directory is in .gitignore, so generated l10n files are not committed. CI needs to generate them before running analyze, otherwise any code using new l10n keys will fail with undefined_getter. Co-Authored-By: Claude Opus 4.5 * chore: trigger CI rebuild * ci: add debug output and mkdir for l10n generation * chore(l10n): remove pnpGuestWiFiDesc key (deleted in dev-2.5.0) The key was removed in dev-2.5.0 (PR #979), causing CI failures when PR merge commits use the base branch's l10n templates. Remove the key and its usage to align with the target branch state. Co-Authored-By: Claude Opus 4.5 * chore(ci): remove debug output from gen-l10n step Clean up temporary debug logging now that l10n key issue is resolved. Co-Authored-By: Claude Opus 4.5 * fix(dashboard): reset top/bottom bar visibility when navigating back When user scrolls down on dashboard (hiding bars), then navigates to a sub-page and pops back, the bars remained hidden even though scroll position was reset. This happened because the dashboard widget stays in the navigation stack during push, so initState doesn't re-run on pop. Fix: Reset uspBarsVisibleProvider and MenuController visibility in the route builder's postFrameCallback, which runs on every route enter including pop-back scenarios. Co-Authored-By: Claude Opus 4.5 * chore(deps): bump ui_kit_library and generative_ui to v2.25.1 Co-Authored-By: Claude Opus 4.5 * feat(remote): SSE & subscription support for Remote Assistance mode (#987) * feat(remote): enable SSE & subscription for Remote Assistance mode Add BridgeEndpoints configuration to switch between local usp-bridge and Guardian proxy endpoints. Remote mode now uses Guardian's SSE and subscription endpoints instead of being skipped entirely. Co-Authored-By: Claude Opus 4.5 * refactor(sse): introduce Strategy pattern for Local vs Remote SSE operations Refactor SSE subscription management to use Strategy pattern, cleanly separating Local (usp-bridge) and Remote (Guardian proxy) behaviors: - Add SseOperationStrategy interface with LocalSseStrategy and RemoteSseStrategy - Local: direct register (bridge idempotent), auto-resubscribe on reconnect, heartbeat watchdog enabled (45s), proactive auth check - Remote: unregister→delay→register to avoid ID conflicts, no auto-resubscribe (orchestrator controls), heartbeat disabled, fire-and-forget cleanup on exit - Fix Guardian endpoint paths (add /v1/guardians prefix) - Fix UspClient.fromBuilder to accept baseUrl for Remote mode - Add clientTypeId header support for Guardian API calls - Add listSubscriptions() API for Remote subscription management - Update tests and add strategy-specific test coverage Co-Authored-By: Claude Opus 4.5 * fix(sse): handle 401 auth failure correctly per connection mode - W-1: Add AuthBehavior to strategy pattern — Local mode retries with reauth, Remote mode triggers force logout (no refresh token) - W-4: Use Riverpod select() to watch only config field, avoiding rebuilds on intermediate state changes - Add SessionExpiredException for unrecoverable auth failures - Wire onAuthFailed callback to trigger logout in both modes Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 * fix: resolve PR #979 review criticals and localize dashboard cards (#991) * refactor(_shared): move HealthTierExt out of wifi_ui to its own domain HealthTierExt.resolveLabel() lived in wifi_ui.dart (a WiFi-signal display helper), which forced every health-score consumer to import the WiFi domain just to render a tier label — an SRP/layering violation flagged in PR #979 review. Move the extension next to HealthTier in network_health_helpers.dart and drop the now-unneeded wifi_ui imports from the 5 affected files. Also document the three look-alike tier enums (NodeSignalLevel, SignalTier, HealthTier) with their data source, purpose, and a "do NOT merge" note plus cross-references, since their overlapping value names invite accidental merging. * fix(firewall): localize TR-181 rule target values in overview chart The rule-target donut passed raw device strings straight to the UI: the pie section label used `entry.key` unfiltered (visible on the chart since AppPieChart defaults showLabels=true), and `_localizeTarget()` only handled Accept/Drop/Other — so Reject/Return/TargetChain fell through untranslated for non-English users. Route the section label through `_localizeTarget()` and add the missing TR-181 enum cases, with new reject/returnTarget/targetChain keys across all 25 locales. Also remove the unused SignalTierExt.label getter (hardcoded English): it had no callers anywhere, and resolveLabel(context) is the localized replacement — keeping it only invited accidental misuse. * fix(dashboard): localize hardcoded strings across info cards Several dashboard cards rendered raw English literals regardless of locale: - LAN card: "DHCP Enabled/Disabled" composed from hardcoded words - Connected Devices card: "Online" / "Offline" filter labels - Time Settings card: DST "On" / "Off" value - Topology node popup: Role/Master/Slave/Model/Manufacturer/Firmware labels - WiFi Status card: access-point encryptionMode "None" bypassed wifiDisplayValue - WiFi Status card: Tx Power "Max" came straight from the model getter Route all of these through loc()/wifiDisplayValue at the View layer. For Tx Power, the WifiRadioUIModel.txPowerDisplay getter stays raw (PDF/AI export callers need English) and only the UI call site localizes the "Max" label, mirroring the getter's `transmitPower == -1` condition exactly. Add on/role/master/slave keys across all 25 locales (model/manufacturer/ firmware/maxShort already existed). * fix(sse): add 'remote-' prefix to Remote subscription IDs When Local and Remote modes connect to the same router simultaneously, they share the same OBUSPA subscription namespace. Using identical subscription IDs causes conflicts and cleanup issues. This fix adds a 'remote-' prefix to all Remote subscription IDs: - registerSubscriptions: sends prefixed ID to bridge - unregisterSubscriptions: converts original ID to prefixed - _fireAndForgetCleanup: only cleans up 'remote-' prefixed subscriptions The prefix conversion is transparent to Registry/Manager (records store original IDs). Co-Authored-By: Claude Opus 4.5 * fix(internet): detect bridge mode from AddressingType only Drop the fragile bridgeEnabled heuristic from UspWanConnectionType. fromRawFields. Device.Bridging.Bridge.Enable is always true on most routers (LAN-side L2 bridge), so detection now keys solely on the WAN interface AddressingType: empty/unknown means bridge mode. * feat(internet): add hostName to InternetSettingsReadOnlyInfo Display-only field carrying Device.DeviceInfo.HostName, used to build the https://.local bridge management address. Kept off the editable form so it does not affect dirty checking. * feat(internet): fetch router hostName in settings service Add a targeted GET of Device.DeviceInfo.HostName to the parallel fetch and surface it via readOnlyInfo.hostName. Update the fromRawFields call site to the AddressingType-only signature. * docs(internet): correct _preservedConnectionType comment Describe its real responsibility (transient empty-AddressingType guard after a non-type save, #759) and drop the stale 'remove once bridge via Bridging model' note. Co-Authored-By: Claude Opus 4.8 * l10n(internet): add bridge reconnect hint + redirect dialog strings Co-Authored-By: Claude Opus 4.8 * feat(internet): bold reconnect hint when editing bridge mode When the user is editing and selects Bridge Mode, show a bold line telling them to reconnect at https://.local after saving. Hidden when not editing or when the hostname is unknown. Co-Authored-By: Claude Opus 4.8 * test(internet): assert bridge reconnect hint is bold Harden the positive widget test with a byWidgetPredicate that checks the hint AppText has fontWeight == FontWeight.bold, so a regression back to the non-bold AppText.bodyMedium factory would now fail the test. Co-Authored-By: Claude Opus 4.8 * feat(internet): add bridge redirect dialog helper Dialog shown after entering Bridge Mode: bold message plus a primary button that navigates the browser to https://.local via the existing assignWebLocation barrel. navigate() is injectable for tests. * fix(internet): shorten bridge redirect button to avoid overflow The primary button label was 'Go to ', which overflowed the fixed-width AppDialog action row. Change it to a short 'Go to router' label; the full https://.local address stays in the bold message above. bridgeRedirectButton becomes a plain getter (no {url} placeholder). * feat(internet): show redirect dialog after entering bridge mode On save, if the WAN entered bridge (was not bridge, now is) and a hostname is known, show the redirect dialog instead of the success snackbar. Extract shouldRedirectToBridge as a pure, tested helper. * test(internet): add bridge_editing golden state + complete bridge l10n Add a bridge_editing fixture state (bridge + editing + hostname) so the bold reconnect hint is exercised by the golden test. Golden PNGs are not committed (test/**/goldens/* is gitignored) — they regenerate locally. Add the four bridge-mode strings (bridgeReconnectHint, bridgeRedirectTitle, bridgeRedirectMessage, bridgeRedirectButton) to all 25 non-English locales so they no longer fall back to English. * fix(test): update PnpService get-call count for hostname fetch fetchSettings now issues an extra parallel GET for Device.DeviceInfo.HostName, so the two PnpService integration tests that assert the total get-call count go from 8 to 9. Behavior is correct; only the assertions and their tally comments are updated. * fix(internet): treat bridge-switch disconnect as success (#762) Switching WAN to bridge mode makes the router unreachable on the current origin: the firmware applies the AddressingType="" SET, then bounces the LAN link / reloads the network within ~2s, tearing down the connection that carries the SET response. Verified on-device (fw 1.2.2.26070203): obuspa applies the SET and returns SET_RESP over its local UDS, but the app never receives the HTTP response, so the save surfaced a spurious "something went wrong" error for an operation that actually succeeded. Service layer: - Add _applyBridgeMode(): wrap the terminal bridge SET in a 4s timeout and treat a TimeoutException / transport (Network/Connectivity) error as success — that disconnect is the expected signature of a successful apply. Any fault the router actively returns (validation/resource/auth/partial) still propagates, so a genuine config failure is never hidden. - Reorder saveAll so the terminal bridge SET runs last when entering bridge, letting the FW-spec PPP/VLAN/IPv6 SETs land on a live connection first. Provider layer: - Override save() for the entering-bridge transition: drop SSE intentionally (blocks the recovery dialog from covering the redirect dialog) and skip the post-save re-fetch (the device is gone from this origin). View layer: - Remove the redirect dialog's Close action; once bridge mode is applied, redirecting to https://.local is the only valid next step. Tests: cover bridge SET transport-error->success, Dart timeout->success, real fault->rethrow, and the entering-bridge SET ordering. Co-Authored-By: Claude Opus 4.8 * fix(internet): skip MTU SET in bridge mode (#762) Bridge resets mtu to 0 (sentinel), but saveAll pushed MaxMTUSize=0 and the firmware rejects it (range 64..65535), aborting the save before the bridge SET. Skip the MTU SET in bridge mode. Add a test covering it. Co-Authored-By: Claude Opus 4.8 * docs(error-handling): restore error-handling guides from dev-2.6.0 Restore the three error-handling docs that were accidentally removed during the dev-2.6.0 merge cleanup. Co-Authored-By: Claude Opus 4.8 * docs(usp): sync vendored-artifacts versions with checked-in binaries Correct the Manifest table to match the actual checked-in artifacts, verified against linksys/usp_framework: - usp-codegen: 0.12.5 -> 0.15.3 (version.h + CHANGELOG + binary --version) - usp_client.js / usp_client_bg.wasm: 0.11.1 -> 0.12.0 (pkg manifest; blobs are byte-identical to the framework pkg output) Also replace hard-coded personal absolute paths (/Users/hankyu/...) with linksys-relative paths so the update procedure is machine-agnostic, and bump Last updated to 2026-07-07. * test(internet): add unit tests for UspInternetSettingsForm Cover the form data model that was previously untested: - constructor default values across all 24 fields (incl. serverAddress for PPTP/L2TP) - copyWith updates each field independently without touching others - equality contract: a per-field mutation loop asserts every field participates in `props`, guarding the dirty-check flow against a field being added to the model but omitted from equality. * chore(usp): regenerate ppp_interface.g.dart after YAML field reorder Reorder LowerLayers before LCPEcho in PppInterface to match the updated YAML definition. Field-order-only change from re-running usp-codegen; no behavioral difference. Co-Authored-By: Claude Opus 4.8 * fix(internet): harden WAN type detection and unify connection-type label Address code review feedback on PR #1094: - fromRawFields: only an explicitly empty AddressingType maps to bridge; any other unrecognised value now falls back to DHCP instead of being misclassified as bridge (future/transient firmware values). - Extract the connection-type display label into a single UspWanConnectionTypeLabel extension, replacing the duplicated _connectionTypeLabel in the banner and the IPv4 section. PPTP/L2TP now resolve through the existing l10n keys (connectionTypePptp/L2tp) instead of hardcoded English strings. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Austin Chang Co-authored-by: Claude Opus 4.5 Co-authored-by: Hank Yu <52936029+HankYuLinksys@users.noreply.github.com> Co-authored-by: AustinChangLinksys <79675086+AustinChangLinksys@users.noreply.github.com> Co-authored-by: Hank Yu --- doc/usp/vendored-artifacts.md | 24 +- lib/generated/gre_tunnel.g.dart | 64 ++++ lib/generated/index.dart | 2 + lib/generated/l2tp_tunnel.g.dart | 64 ++++ lib/generated/ppp_interface.g.dart | 13 + lib/l10n/app_ar.arb | 6 +- lib/l10n/app_da.arb | 6 +- lib/l10n/app_de.arb | 6 +- lib/l10n/app_el.arb | 6 +- lib/l10n/app_en.arb | 22 +- lib/l10n/app_es.arb | 6 +- lib/l10n/app_es_ar.arb | 6 +- lib/l10n/app_fi.arb | 6 +- lib/l10n/app_fr.arb | 6 +- lib/l10n/app_fr_ca.arb | 6 +- lib/l10n/app_id.arb | 6 +- lib/l10n/app_it.arb | 6 +- lib/l10n/app_ja.arb | 6 +- lib/l10n/app_ko.arb | 6 +- lib/l10n/app_nb.arb | 6 +- lib/l10n/app_nl.arb | 6 +- lib/l10n/app_pl.arb | 6 +- lib/l10n/app_pt.arb | 6 +- lib/l10n/app_pt_pt.arb | 6 +- lib/l10n/app_ru.arb | 6 +- lib/l10n/app_sv.arb | 6 +- lib/l10n/app_th.arb | 6 +- lib/l10n/app_tr.arb | 6 +- lib/l10n/app_vi.arb | 6 +- lib/l10n/app_zh.arb | 6 +- lib/l10n/app_zh_TW.arb | 6 +- .../internet_settings_read_only_info.dart | 6 + .../models/usp_internet_settings_form.dart | 7 +- .../models/usp_wan_connection_type.dart | 57 ++- .../usp_internet_settings_form_validator.dart | 20 + .../usp_internet_settings_notifier.dart | 57 ++- .../usp_internet_settings_service.dart | 271 +++++++++++-- .../views/components/_components.dart | 1 + .../usp_connection_status_banner.dart | 15 +- .../components/usp_connection_type_label.dart | 23 ++ .../views/helpers/bridge_redirect_dialog.dart | 39 ++ .../views/sections/usp_ipv4_section.dart | 120 +++++- .../views/sections/usp_optional_section.dart | 4 +- .../views/usp_internet_settings_view.dart | 38 +- .../fixtures/internet_settings_test_data.dart | 8 + .../usp_internet_settings_view_test.dart | 9 + .../services/pnp_service_test.dart | 45 ++- ...internet_settings_read_only_info_test.dart | 24 ++ .../usp_internet_settings_form_test.dart | 166 ++++++++ .../models/usp_wan_connection_type_test.dart | 127 +++++-- ...internet_settings_form_validator_test.dart | 142 +++++++ .../usp_internet_settings_notifier_test.dart | 60 +++ .../usp_internet_settings_service_test.dart | 358 +++++++++++++++++- .../helpers/bridge_redirect_dialog_test.dart | 72 ++++ .../usp_ipv4_section_bridge_hint_test.dart | 93 +++++ .../usp_internet_settings_view_save_test.dart | 51 +++ 56 files changed, 1978 insertions(+), 174 deletions(-) create mode 100644 lib/generated/gre_tunnel.g.dart create mode 100644 lib/generated/l2tp_tunnel.g.dart create mode 100644 lib/page/internet_settings/views/components/usp_connection_type_label.dart create mode 100644 lib/page/internet_settings/views/helpers/bridge_redirect_dialog.dart create mode 100644 test/page/internet_settings/models/internet_settings_read_only_info_test.dart create mode 100644 test/page/internet_settings/models/usp_internet_settings_form_test.dart create mode 100644 test/page/internet_settings/views/helpers/bridge_redirect_dialog_test.dart create mode 100644 test/page/internet_settings/views/sections/usp_ipv4_section_bridge_hint_test.dart create mode 100644 test/page/internet_settings/views/usp_internet_settings_view_save_test.dart 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/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/l10n/app_ar.arb b/lib/l10n/app_ar.arb index ce16214b8..809e60592 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -1118,5 +1118,9 @@ "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": "الانتقال إلى جهاز التوجيه" } diff --git a/lib/l10n/app_da.arb b/lib/l10n/app_da.arb index 835592b23..a22d19c13 100644 --- a/lib/l10n/app_da.arb +++ b/lib/l10n/app_da.arb @@ -1118,5 +1118,9 @@ "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" } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 6549e50c1..b395bc36b 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1118,5 +1118,9 @@ "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" } diff --git a/lib/l10n/app_el.arb b/lib/l10n/app_el.arb index a3fa21f91..67bf9c64d 100644 --- a/lib/l10n/app_el.arb +++ b/lib/l10n/app_el.arb @@ -1118,5 +1118,9 @@ "targetChain": "Αλυσίδα προορισμού", "synchronized": "Συγχρονισμένο", "noPortForwardingRulesConfigured": "Δεν έχουν διαμορφωθεί κανόνες προώθησης θυρών", - "nItems": "{count, plural, one{{count} στοιχείο} other{{count} στοιχεία}}" + "nItems": "{count, plural, one{{count} στοιχείο} other{{count} στοιχεία}}", + "bridgeReconnectHint": "Μετά την αποθήκευση, επανασυνδεθείτε στον δρομολογητή σας στη διεύθυνση {url}.", + "bridgeRedirectTitle": "Επανασύνδεση στον δρομολογητή σας", + "bridgeRedirectMessage": "Ο δρομολογητής σας λειτουργεί τώρα ως διαφανής γέφυρα και δεν εκχωρεί πλέον τοπικές διευθύνσεις IP. Επανασυνδεθείτε στη διεύθυνση {url}.", + "bridgeRedirectButton": "Μετάβαση στον δρομολογητή" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index a8fedb4c6..e497a19c4 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1695,5 +1695,25 @@ "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" } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 85d0b7237..3ad1e5a1a 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1118,5 +1118,9 @@ "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" } diff --git a/lib/l10n/app_es_ar.arb b/lib/l10n/app_es_ar.arb index 295fce98c..2f9cd3088 100644 --- a/lib/l10n/app_es_ar.arb +++ b/lib/l10n/app_es_ar.arb @@ -1118,5 +1118,9 @@ "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" } diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb index c0f7e4e4b..884bb8bc5 100644 --- a/lib/l10n/app_fi.arb +++ b/lib/l10n/app_fi.arb @@ -1118,5 +1118,9 @@ "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" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 2b7ea7ebe..3c45b60be 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1118,5 +1118,9 @@ "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" } diff --git a/lib/l10n/app_fr_ca.arb b/lib/l10n/app_fr_ca.arb index 4d7c244eb..31c826d5d 100644 --- a/lib/l10n/app_fr_ca.arb +++ b/lib/l10n/app_fr_ca.arb @@ -1118,5 +1118,9 @@ "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" } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index a85987c88..9f6326fb8 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -1118,5 +1118,9 @@ "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" } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index d265bb094..f81473fa2 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -1118,5 +1118,9 @@ "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" } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 0849626a6..2650206be 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -1118,5 +1118,9 @@ "targetChain": "ターゲットチェーン", "synchronized": "同期済み", "noPortForwardingRulesConfigured": "ポート転送ルールが設定されていません", - "nItems": "{count, plural, other{{count} 個の項目}}" + "nItems": "{count, plural, other{{count} 個の項目}}", + "bridgeReconnectHint": "保存後、{url} からルーターに再接続してください。", + "bridgeRedirectTitle": "ルーターに再接続", + "bridgeRedirectMessage": "ルーターは現在、透過的なブリッジとして動作しており、ローカル IP アドレスを割り当てません。{url} から再接続してください。", + "bridgeRedirectButton": "ルーターに移動" } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 1e32f1c6d..b00d3929b 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -1118,5 +1118,9 @@ "targetChain": "대상 체인", "synchronized": "동기화됨", "noPortForwardingRulesConfigured": "구성된 포트 포워딩 규칙이 없습니다", - "nItems": "{count, plural, other{{count}개 항목}}" + "nItems": "{count, plural, other{{count}개 항목}}", + "bridgeReconnectHint": "저장한 후 {url} 에서 라우터에 다시 연결하세요.", + "bridgeRedirectTitle": "라우터에 다시 연결", + "bridgeRedirectMessage": "라우터가 이제 투명 브리지로 작동하며 로컬 IP 주소를 더 이상 할당하지 않습니다. {url} 에서 다시 연결하세요.", + "bridgeRedirectButton": "라우터로 이동" } diff --git a/lib/l10n/app_nb.arb b/lib/l10n/app_nb.arb index d15b10157..ff1ebb26a 100644 --- a/lib/l10n/app_nb.arb +++ b/lib/l10n/app_nb.arb @@ -1118,5 +1118,9 @@ "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" } diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 183fc7eb4..2b4c7b632 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -1118,5 +1118,9 @@ "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" } diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index f67a24f25..ecc0fe20f 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -1118,5 +1118,9 @@ "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" } diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 54acbb43b..cb93ac4df 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -1118,5 +1118,9 @@ "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" } diff --git a/lib/l10n/app_pt_pt.arb b/lib/l10n/app_pt_pt.arb index 5197cf382..9b34849d1 100644 --- a/lib/l10n/app_pt_pt.arb +++ b/lib/l10n/app_pt_pt.arb @@ -1118,5 +1118,9 @@ "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" } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index af58799be..4d346cb64 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1118,5 +1118,9 @@ "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": "Перейти к маршрутизатору" } diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index c17f5b799..fffd68213 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -1118,5 +1118,9 @@ "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" } diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index fc3ff6fc2..c1970e777 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -1118,5 +1118,9 @@ "targetChain": "เชนเป้าหมาย", "synchronized": "ซิงโครไนซ์แล้ว", "noPortForwardingRulesConfigured": "ไม่มีการกำหนดค่ากฎการส่งต่อพอร์ต", - "nItems": "{count, plural, other{{count} รายการ}}" + "nItems": "{count, plural, other{{count} รายการ}}", + "bridgeReconnectHint": "หลังจากบันทึกแล้ว โปรดเชื่อมต่อกับเราเตอร์ของคุณอีกครั้งที่ {url}", + "bridgeRedirectTitle": "เชื่อมต่อกับเราเตอร์ของคุณอีกครั้ง", + "bridgeRedirectMessage": "ขณะนี้เราเตอร์ของคุณทำงานเป็นบริดจ์แบบโปร่งใสและไม่แจกจ่ายที่อยู่ IP ภายในอีกต่อไป โปรดเชื่อมต่อใหม่ที่ {url}", + "bridgeRedirectButton": "ไปที่เราเตอร์" } diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index da600c49a..3a76b5823 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -1118,5 +1118,9 @@ "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" } diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 3625612e5..85c5ad956 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -1118,5 +1118,9 @@ "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" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 791fb3ca2..2728c144d 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1118,5 +1118,9 @@ "targetChain": "目标链", "synchronized": "已同步", "noPortForwardingRulesConfigured": "未配置端口转发规则", - "nItems": "{count, plural, other{{count} 个项目}}" + "nItems": "{count, plural, other{{count} 个项目}}", + "bridgeReconnectHint": "保存后,请改用 {url} 重新连接到您的路由器。", + "bridgeRedirectTitle": "重新连接到您的路由器", + "bridgeRedirectMessage": "您的路由器现在是透明网桥,不再分配本地 IP 地址。请改用 {url} 重新连接。", + "bridgeRedirectButton": "前往路由器" } diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 3fec4dc08..b801cfef2 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -1118,5 +1118,9 @@ "targetChain": "目標鏈", "synchronized": "已同步", "noPortForwardingRulesConfigured": "未設定連接埠轉發規則", - "nItems": "{count, plural, other{{count} 個項目}}" + "nItems": "{count, plural, other{{count} 個項目}}", + "bridgeReconnectHint": "儲存後,請改用 {url} 重新連線到您的路由器。", + "bridgeRedirectTitle": "重新連線到您的路由器", + "bridgeRedirectMessage": "您的路由器現在是透明橋接器,不再配發本機 IP 位址。請改用 {url} 重新連線。", + "bridgeRedirectButton": "前往路由器" } 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..235828312 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,19 @@ enum UspWanConnectionType { dhcp => 'DHCP', staticIp => 'Static', pppoe => 'IPCP', + pptp => 'IPCP', + l2tp => 'IPCP', bridge => '', // issue #14: empty string = proto=none }; + + /// 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..751b178cb 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 @@ -180,6 +182,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 // --------------------------------------------------------------------------- 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..0fbfb68c6 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 // --------------------------------------------------------------------------- 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..1937906cb 100644 --- a/lib/page/internet_settings/views/sections/usp_optional_section.dart +++ b/lib/page/internet_settings/views/sections/usp_optional_section.dart @@ -59,7 +59,9 @@ class _UspOptionalSectionState extends ConsumerState { // 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) + UspWanConnectionType.pptp || + UspWanConnectionType.l2tp => + 1460, // tunnel overhead _ => 1500, // Ethernet standard (DHCP, Static, Bridge) }; } 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 da2fc4560..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'; @@ -184,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) { @@ -199,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/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/page/instant_setup/services/pnp_service_test.dart b/test/page/instant_setup/services/pnp_service_test.dart index 7234d0540..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(); 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..cee122f6b 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,64 @@ 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); + }); + }); }); } 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..0bd89babe 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, (_, __) {}); @@ -226,6 +234,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); 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..9d7a79619 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', () { 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, + ); + }); + }); +} From de7d99be8fec4eaf559a304489103d43ff16f596 Mon Sep 17 00:00:00 2001 From: Peter Jhong <52424995+PeterJhongLinksys@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:16:13 +0800 Subject: [PATCH 31/56] fix(internet): auto-fill and clamp MTU on connection type switch (#1083) (#1114) When switching WAN connection type, the MTU field was left in an invalid or empty state: changing to PPPoE kept 1500 (over the 1492 limit), and leaving Bridge mode left the field blank (mtu = 0 = auto). Introduce a single MTU range source of truth on UspWanConnectionType (mtuMin/mtuMax/clampMtu) and normalize MTU in updateConnectionType: keep the current value when it fits the new type's range, otherwise fall back to the type max. Bridge continues to use auto (mtu = 0). The view now references the enum getters instead of duplicating the range logic. Covers issue expectations: over-limit values are clamped (1500 -> 1492), empty/auto values are auto-filled with the type max, and an already-valid value is preserved across the switch. Co-authored-by: Claude Opus 4.8 --- .../models/usp_wan_connection_type.dart | 18 ++++++ .../usp_internet_settings_notifier.dart | 9 ++- .../views/sections/usp_optional_section.dart | 16 ++--- .../models/usp_wan_connection_type_test.dart | 58 ++++++++++++++++++ .../usp_internet_settings_notifier_test.dart | 61 +++++++++++++++++++ 5 files changed, 149 insertions(+), 13 deletions(-) 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 235828312..e96bf3f12 100644 --- a/lib/page/internet_settings/models/usp_wan_connection_type.dart +++ b/lib/page/internet_settings/models/usp_wan_connection_type.dart @@ -66,6 +66,24 @@ enum UspWanConnectionType { 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; 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 751b178cb..ef16135b1 100644 --- a/lib/page/internet_settings/providers/usp_internet_settings_notifier.dart +++ b/lib/page/internet_settings/providers/usp_internet_settings_notifier.dart @@ -268,9 +268,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/views/sections/usp_optional_section.dart b/lib/page/internet_settings/views/sections/usp_optional_section.dart index 1937906cb..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,18 +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) - UspWanConnectionType.pptp || - UspWanConnectionType.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/test/page/internet_settings/models/usp_wan_connection_type_test.dart b/test/page/internet_settings/models/usp_wan_connection_type_test.dart index cee122f6b..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 @@ -172,5 +172,63 @@ void main() { 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_notifier_test.dart b/test/page/internet_settings/providers/usp_internet_settings_notifier_test.dart index 0bd89babe..301afeacd 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 @@ -181,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); From 690bb46b8d3a46ce8cc3be36999640146f939671 Mon Sep 17 00:00:00 2001 From: Hank Yu Date: Wed, 8 Jul 2026 16:54:50 +0800 Subject: [PATCH 32/56] fix(instant-privacy): warn before enabling when devices use private MAC - Detect locally-administered (private/randomized) MAC addresses among connected devices via OuiLookup - Surface a warning with the affected device list inside the enable confirmation dialog, so users can turn off "Private WiFi Address" before locking the network and avoid being blocked after MAC rotation - Add isPrivateMac flag to the device UI model, populated in the service - Add unit tests covering private vs universal MAC detection --- lib/l10n/app_en.arb | 2 + .../instant_privacy_device_ui_model.dart | 10 ++- .../services/instant_privacy_service.dart | 2 + .../views/instant_privacy_view.dart | 85 +++++++++++++++++-- .../usp_instant_privacy_service_test.dart | 22 +++++ 5 files changed, 111 insertions(+), 10 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index e497a19c4..7c3ca0903 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1399,6 +1399,8 @@ } } }, + "privateMacWarningTitle": "Some devices use a private WiFi address", + "privateMacWarningDesc": "The device(s) below use a private (randomized) WiFi 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 WiFi Address\" for these devices before enabling Instant Privacy.", "disableInstantPrivacyTitle": "Disable Instant Privacy?", "disableInstantPrivacyDesc": "All devices will be able to connect freely to your network.", "addDeviceManually": "Add device manually", 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..316c26bcf 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(); } diff --git a/lib/page/instant_privacy/views/instant_privacy_view.dart b/lib/page/instant_privacy/views/instant_privacy_view.dart index a3481e13e..5375b21df 100644 --- a/lib/page/instant_privacy/views/instant_privacy_view.dart +++ b/lib/page/instant_privacy/views/instant_privacy_view.dart @@ -217,24 +217,91 @@ class InstantPrivacyView extends ConsumerWidget { ); } + // --------------------------------------------------------------------------- + // Private (randomized) MAC warning + // --------------------------------------------------------------------------- + + Widget _buildPrivateMacWarning( + BuildContext context, + List devices, + ) { + final colorScheme = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.only(top: AppSpacing.md), + child: Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: colorScheme.errorContainer, + borderRadius: BorderRadius.circular(AppSpacing.sm), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppIcon.font( + Icons.warning_amber_rounded, + size: 20, + color: colorScheme.onErrorContainer, + ), + AppGap.sm(), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppText.labelLarge( + loc(context).privateMacWarningTitle, + color: colorScheme.onErrorContainer, + ), + AppGap.xs(), + AppText.bodySmall( + loc(context).privateMacWarningDesc, + color: colorScheme.onErrorContainer, + ), + ], + ), + ), + ], + ), + AppGap.sm(), + for (final device in devices) + Padding( + padding: const EdgeInsets.only(top: AppSpacing.xs), + child: AppText.bodySmall( + '• ${device.displayName} (${device.mac})', + color: colorScheme.onErrorContainer, + ), + ), + ], + ), + ), + ); + } + // --------------------------------------------------------------------------- // 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) + _buildPrivateMacWarning(context, privateMacDevices), + ], ), actions: [ AppButton.text( 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..47bf0cf0c 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); + }); }); // --------------------------------------------------------------------------- From dbd376a1326a34812a9cc9ec7555dc6307b5da15 Mon Sep 17 00:00:00 2001 From: Hank Yu Date: Wed, 8 Jul 2026 23:46:24 +0800 Subject: [PATCH 33/56] i18n(instant-privacy): translate private MAC warning; unify Wi-Fi spelling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add privateMacWarningTitle / privateMacWarningDesc for all 25 non-English locales - Apply native-review fixes: zh_TW use 私密 (Apple's term), it use compreso, vi align setting name to OS term, nl fix spacing - Standardize "WiFi" to "Wi-Fi" across the warning strings, incl. en --- lib/l10n/app_ar.arb | 2 ++ lib/l10n/app_da.arb | 2 ++ lib/l10n/app_de.arb | 2 ++ lib/l10n/app_el.arb | 2 ++ lib/l10n/app_en.arb | 4 ++-- lib/l10n/app_es.arb | 2 ++ lib/l10n/app_es_ar.arb | 2 ++ lib/l10n/app_fi.arb | 2 ++ lib/l10n/app_fr.arb | 2 ++ lib/l10n/app_fr_ca.arb | 2 ++ lib/l10n/app_id.arb | 2 ++ lib/l10n/app_it.arb | 2 ++ lib/l10n/app_ja.arb | 2 ++ lib/l10n/app_ko.arb | 2 ++ lib/l10n/app_nb.arb | 2 ++ lib/l10n/app_nl.arb | 2 ++ lib/l10n/app_pl.arb | 2 ++ lib/l10n/app_pt.arb | 2 ++ lib/l10n/app_pt_pt.arb | 2 ++ lib/l10n/app_ru.arb | 2 ++ lib/l10n/app_sv.arb | 2 ++ lib/l10n/app_th.arb | 2 ++ lib/l10n/app_tr.arb | 2 ++ lib/l10n/app_vi.arb | 2 ++ lib/l10n/app_zh.arb | 2 ++ lib/l10n/app_zh_TW.arb | 2 ++ 26 files changed, 52 insertions(+), 2 deletions(-) diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 809e60592..8e5ceffb8 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -560,6 +560,8 @@ "diagnosticsRecWeakWifiTitle": "إشارة WiFi ضعيفة", "disable": "تعطيل", "disableInstantPrivacyDesc": "ستتمكن جميع الأجهزة من الاتصال بشبكتك بحرية.", + "privateMacWarningTitle": "تستخدم بعض الأجهزة عنوان Wi-Fi خاصًا", + "privateMacWarningDesc": "تستخدم الأجهزة المذكورة أدناه عنوان Wi-Fi خاصًا (عشوائيًا). ونظرًا لأن هذا العنوان يتغير بمرور الوقت، فقد يتم حظرها بعد تبديله — حتى الجهاز الذي تستخدمه الآن. للبقاء متصلاً، أوقف تشغيل «عنوان Wi-Fi خاص» لهذه الأجهزة قبل تمكين الخصوصية الفورية.", "disableInstantPrivacyTitle": "تعطيل الخصوصية الفورية؟", "discards": "حالات التجاهل", "distribution": "التوزيع", diff --git a/lib/l10n/app_da.arb b/lib/l10n/app_da.arb index a22d19c13..369b4c2e2 100644 --- a/lib/l10n/app_da.arb +++ b/lib/l10n/app_da.arb @@ -559,6 +559,8 @@ "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.", "disableInstantPrivacyTitle": "Deaktiver Instant Privacy?", "discards": "Forkastninger", "distribution": "Fordeling", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index b395bc36b..8f18d24d7 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -559,6 +559,8 @@ "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.", "disableInstantPrivacyTitle": "Instant Privacy deaktivieren?", "discards": "Verworfene Pakete", "distribution": "Verteilung", diff --git a/lib/l10n/app_el.arb b/lib/l10n/app_el.arb index 67bf9c64d..a991b06ac 100644 --- a/lib/l10n/app_el.arb +++ b/lib/l10n/app_el.arb @@ -557,6 +557,8 @@ "diagnosticsRecWeakWifiTitle": "Αδύναμο σήμα WiFi", "disable": "Απενεργοποίηση", "disableInstantPrivacyDesc": "Όλες οι συσκευές θα μπορούν να συνδέονται ελεύθερα στο δίκτυό σας.", + "privateMacWarningTitle": "Ορισμένες συσκευές χρησιμοποιούν ιδιωτική διεύθυνση Wi-Fi", + "privateMacWarningDesc": "Οι παρακάτω συσκευές χρησιμοποιούν ιδιωτική (τυχαία) διεύθυνση Wi-Fi. Επειδή αυτή η διεύθυνση αλλάζει με την πάροδο του χρόνου, ενδέχεται να αποκλειστούν μετά την εναλλαγή της — ακόμη και αυτή που χρησιμοποιείτε τώρα. Για να παραμείνετε συνδεδεμένοι, απενεργοποιήστε την «Ιδιωτική διεύθυνση Wi-Fi» για αυτές τις συσκευές πριν ενεργοποιήσετε το Instant Privacy.", "disableInstantPrivacyTitle": "Απενεργοποίηση Instant Privacy;", "discards": "Απορρίψεις", "distribution": "Κατανομή", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 7c3ca0903..0197eacef 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1399,8 +1399,8 @@ } } }, - "privateMacWarningTitle": "Some devices use a private WiFi address", - "privateMacWarningDesc": "The device(s) below use a private (randomized) WiFi 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 WiFi Address\" for these devices before enabling Instant Privacy.", + "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.", "disableInstantPrivacyTitle": "Disable Instant Privacy?", "disableInstantPrivacyDesc": "All devices will be able to connect freely to your network.", "addDeviceManually": "Add device manually", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 3ad1e5a1a..5203e6707 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -559,6 +559,8 @@ "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.", "disableInstantPrivacyTitle": "¿Desactivar Instant-Privacidad?", "discards": "Descartes", "distribution": "Distribución", diff --git a/lib/l10n/app_es_ar.arb b/lib/l10n/app_es_ar.arb index 2f9cd3088..8a1cec563 100644 --- a/lib/l10n/app_es_ar.arb +++ b/lib/l10n/app_es_ar.arb @@ -559,6 +559,8 @@ "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.", "disableInstantPrivacyTitle": "¿Desactivar la Privacidad instantánea?", "discards": "Descartes", "distribution": "Distribución", diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb index 884bb8bc5..6a186ddff 100644 --- a/lib/l10n/app_fi.arb +++ b/lib/l10n/app_fi.arb @@ -557,6 +557,8 @@ "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.", "disableInstantPrivacyTitle": "Poistetaanko Instant Privacy käytöstä?", "discards": "Hylätyt", "distribution": "Jakauma", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 3c45b60be..725e5723c 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -559,6 +559,8 @@ "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.", "disableInstantPrivacyTitle": "Désactiver Instant Privacy ?", "discards": "Rejets", "distribution": "Répartition", diff --git a/lib/l10n/app_fr_ca.arb b/lib/l10n/app_fr_ca.arb index 31c826d5d..5c24493ed 100644 --- a/lib/l10n/app_fr_ca.arb +++ b/lib/l10n/app_fr_ca.arb @@ -559,6 +559,8 @@ "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é.", "disableInstantPrivacyTitle": "Désactiver Instant-Confidentialité?", "discards": "Rejets", "distribution": "Répartition", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 9f6326fb8..3198352b3 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -557,6 +557,8 @@ "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.", "disableInstantPrivacyTitle": "Nonaktifkan Privasi Instan?", "discards": "Pembuangan", "distribution": "Distribusi", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index f81473fa2..60c7f7f97 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -559,6 +559,8 @@ "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.", "disableInstantPrivacyTitle": "Disattivare Instant Privacy?", "discards": "Scarti", "distribution": "Distribuzione", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 2650206be..46f97f98b 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -560,6 +560,8 @@ "diagnosticsRecWeakWifiTitle": "WiFi 信号が弱い", "disable": "無効にする", "disableInstantPrivacyDesc": "すべてのデバイスがネットワークに自由に接続できるようになります。", + "privateMacWarningTitle": "一部のデバイスはプライベート Wi-Fi アドレスを使用しています", + "privateMacWarningDesc": "以下のデバイスはプライベート(ランダム化された)Wi-Fi アドレスを使用しています。このアドレスは時間の経過とともに変化するため、切り替わった後にブロックされる可能性があります。現在使用しているデバイスも例外ではありません。接続を維持するには、Instant Privacy を有効にする前に、これらのデバイスの「プライベート Wi-Fi アドレス」をオフにしてください。", "disableInstantPrivacyTitle": "Instant Privacy を無効にしますか?", "discards": "破棄", "distribution": "分布", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index b00d3929b..00eec713a 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -557,6 +557,8 @@ "diagnosticsRecWeakWifiTitle": "약한 WiFi 신호", "disable": "비활성화", "disableInstantPrivacyDesc": "모든 장치가 네트워크에 자유롭게 연결할 수 있게 됩니다.", + "privateMacWarningTitle": "일부 기기가 비공개 Wi-Fi 주소를 사용합니다", + "privateMacWarningDesc": "아래 기기는 비공개(무작위) Wi-Fi 주소를 사용합니다. 이 주소는 시간이 지나면 변경되므로 주소가 바뀐 후에는 차단될 수 있으며, 지금 사용 중인 기기도 예외가 아닙니다. 계속 연결하려면 Instant Privacy를 활성화하기 전에 이 기기들의 “비공개 Wi-Fi 주소”를 꺼 주세요.", "disableInstantPrivacyTitle": "Instant Privacy를 비활성화하시겠습니까?", "discards": "폐기", "distribution": "분포", diff --git a/lib/l10n/app_nb.arb b/lib/l10n/app_nb.arb index ff1ebb26a..4535e5a82 100644 --- a/lib/l10n/app_nb.arb +++ b/lib/l10n/app_nb.arb @@ -559,6 +559,8 @@ "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.", "disableInstantPrivacyTitle": "Deaktivere Instant Privacy?", "discards": "Forkastninger", "distribution": "Fordeling", diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 2b4c7b632..9509a77e7 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -559,6 +559,8 @@ "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.", "disableInstantPrivacyTitle": "Instant Privacy uitschakelen?", "discards": "Verworpen", "distribution": "Verdeling", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index ecc0fe20f..0e878f335 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -557,6 +557,8 @@ "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.", "disableInstantPrivacyTitle": "Wyłączyć Instant Privacy?", "discards": "Odrzucenia", "distribution": "Rozkład", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index cb93ac4df..a45817c17 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -559,6 +559,8 @@ "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.", "disableInstantPrivacyTitle": "Desativar o Instant Privacy?", "discards": "Descartes", "distribution": "Distribuição", diff --git a/lib/l10n/app_pt_pt.arb b/lib/l10n/app_pt_pt.arb index 9b34849d1..96b892239 100644 --- a/lib/l10n/app_pt_pt.arb +++ b/lib/l10n/app_pt_pt.arb @@ -559,6 +559,8 @@ "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.", "disableInstantPrivacyTitle": "Desativar Privacidade Instantânea?", "discards": "Descartes", "distribution": "Distribuição", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 4d346cb64..664cd8fba 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -557,6 +557,8 @@ "diagnosticsRecWeakWifiTitle": "Слабый сигнал WiFi", "disable": "Отключить", "disableInstantPrivacyDesc": "Все устройства смогут свободно подключаться к вашей сети.", + "privateMacWarningTitle": "Некоторые устройства используют частный адрес Wi-Fi", + "privateMacWarningDesc": "Устройства, перечисленные ниже, используют частный (случайный) адрес Wi-Fi. Поскольку этот адрес со временем меняется, после его смены они могут быть заблокированы — даже то, которым вы пользуетесь сейчас. Чтобы не потерять подключение, отключите «Частный адрес Wi-Fi» для этих устройств перед включением Instant Privacy.", "disableInstantPrivacyTitle": "Отключить Instant Privacy?", "discards": "Отброшено", "distribution": "Распределение", diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index fffd68213..d3116ff91 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -559,6 +559,8 @@ "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.", "disableInstantPrivacyTitle": "Inaktivera Instant Privacy?", "discards": "Kasserade", "distribution": "Fördelning", diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index c1970e777..307b02a5e 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -557,6 +557,8 @@ "diagnosticsRecWeakWifiTitle": "สัญญาณ WiFi อ่อน", "disable": "ปิดใช้งาน", "disableInstantPrivacyDesc": "อุปกรณ์ทั้งหมดจะสามารถเชื่อมต่อกับเครือข่ายของคุณได้อย่างอิสระ", + "privateMacWarningTitle": "อุปกรณ์บางเครื่องใช้ที่อยู่ Wi-Fi ส่วนตัว", + "privateMacWarningDesc": "อุปกรณ์ด้านล่างนี้ใช้ที่อยู่ Wi-Fi ส่วนตัว (แบบสุ่ม) เนื่องจากที่อยู่นี้เปลี่ยนแปลงไปตามเวลา อุปกรณ์เหล่านี้อาจถูกบล็อกหลังจากที่อยู่เปลี่ยน — แม้แต่เครื่องที่คุณกำลังใช้อยู่ตอนนี้ เพื่อให้เชื่อมต่ออยู่เสมอ โปรดปิด \"ที่อยู่ Wi-Fi ส่วนตัว\" สำหรับอุปกรณ์เหล่านี้ก่อนเปิดใช้งาน Instant Privacy", "disableInstantPrivacyTitle": "ปิดใช้งาน Instant Privacy หรือไม่", "discards": "การละทิ้ง", "distribution": "การกระจาย", diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index 3a76b5823..639a5497e 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -557,6 +557,8 @@ "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.", "disableInstantPrivacyTitle": "Anında Gizlilik Devre Dışı Bırakılsın mı?", "discards": "Atılanlar", "distribution": "Dağılım", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 85c5ad956..571e1b081 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -557,6 +557,8 @@ "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ì.", "disableInstantPrivacyTitle": "Tắt Quyền riêng tư tức thì?", "discards": "Loại bỏ", "distribution": "Phân bố", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 2728c144d..069a0bf65 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -560,6 +560,8 @@ "diagnosticsRecWeakWifiTitle": "WiFi 信号弱", "disable": "禁用", "disableInstantPrivacyDesc": "所有设备都将能够自由连接到您的网络。", + "privateMacWarningTitle": "部分设备使用私有 Wi-Fi 地址", + "privateMacWarningDesc": "以下设备使用私有(随机)Wi-Fi 地址。由于该地址会随时间变化,轮换后这些设备可能会被屏蔽——即使是您现在正在使用的设备。为保持连接,请在启用即时隐私前,关闭这些设备的“私有 Wi-Fi 地址”。", "disableInstantPrivacyTitle": "禁用即时隐私?", "discards": "丢弃", "distribution": "分布", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index b801cfef2..d31bbbf96 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -560,6 +560,8 @@ "diagnosticsRecWeakWifiTitle": "WiFi 訊號微弱", "disable": "停用", "disableInstantPrivacyDesc": "所有裝置都將能自由連線至您的網路。", + "privateMacWarningTitle": "部分裝置使用私密 Wi-Fi 位址", + "privateMacWarningDesc": "以下裝置使用私密(隨機)Wi-Fi 位址。由於該位址會隨時間變更,輪替後這些裝置可能會被封鎖——即使是您目前正在使用的裝置。為維持連線,請在啟用即時隱私前,關閉這些裝置的「私密 Wi-Fi 位址」。", "disableInstantPrivacyTitle": "停用即時隱私?", "discards": "捨棄", "distribution": "分布", From c4b6adb54696ae0a66b96224af62c6ee43aa6146 Mon Sep 17 00:00:00 2001 From: Hank Yu Date: Thu, 9 Jul 2026 14:50:14 +0800 Subject: [PATCH 34/56] refactor(instant-privacy): show private MAC warning on page instead of dialog - Move warning description to page banner (below feature desc) - Add red "Private" badge on device list items with private MAC - Simplify enable dialog to show title-only warning - Set isPrivateMac flag on allowed devices (ON list) too - Add privateMacLabel translations for all 26 locales --- lib/l10n/app_ar.arb | 1 + lib/l10n/app_da.arb | 1 + lib/l10n/app_de.arb | 1 + lib/l10n/app_el.arb | 1 + lib/l10n/app_en.arb | 1 + lib/l10n/app_es.arb | 1 + lib/l10n/app_es_ar.arb | 1 + lib/l10n/app_fi.arb | 1 + lib/l10n/app_fr.arb | 1 + lib/l10n/app_fr_ca.arb | 1 + lib/l10n/app_id.arb | 1 + lib/l10n/app_it.arb | 1 + lib/l10n/app_ja.arb | 1 + lib/l10n/app_ko.arb | 1 + lib/l10n/app_nb.arb | 1 + lib/l10n/app_nl.arb | 1 + lib/l10n/app_pl.arb | 1 + lib/l10n/app_pt.arb | 1 + lib/l10n/app_pt_pt.arb | 1 + lib/l10n/app_ru.arb | 1 + lib/l10n/app_sv.arb | 1 + lib/l10n/app_th.arb | 1 + lib/l10n/app_tr.arb | 1 + lib/l10n/app_vi.arb | 1 + lib/l10n/app_zh.arb | 1 + lib/l10n/app_zh_TW.arb | 1 + .../services/instant_privacy_service.dart | 6 +- .../views/instant_privacy_view.dart | 118 ++++++++++-------- .../usp_instant_privacy_service_test.dart | 18 +++ 29 files changed, 114 insertions(+), 54 deletions(-) diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 8e5ceffb8..d80ba5e3f 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -562,6 +562,7 @@ "disableInstantPrivacyDesc": "ستتمكن جميع الأجهزة من الاتصال بشبكتك بحرية.", "privateMacWarningTitle": "تستخدم بعض الأجهزة عنوان Wi-Fi خاصًا", "privateMacWarningDesc": "تستخدم الأجهزة المذكورة أدناه عنوان Wi-Fi خاصًا (عشوائيًا). ونظرًا لأن هذا العنوان يتغير بمرور الوقت، فقد يتم حظرها بعد تبديله — حتى الجهاز الذي تستخدمه الآن. للبقاء متصلاً، أوقف تشغيل «عنوان Wi-Fi خاص» لهذه الأجهزة قبل تمكين الخصوصية الفورية.", + "privateMacLabel": "خاص", "disableInstantPrivacyTitle": "تعطيل الخصوصية الفورية؟", "discards": "حالات التجاهل", "distribution": "التوزيع", diff --git a/lib/l10n/app_da.arb b/lib/l10n/app_da.arb index 369b4c2e2..e5c49c754 100644 --- a/lib/l10n/app_da.arb +++ b/lib/l10n/app_da.arb @@ -561,6 +561,7 @@ "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", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 8f18d24d7..e4e2dcf58 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -561,6 +561,7 @@ "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", diff --git a/lib/l10n/app_el.arb b/lib/l10n/app_el.arb index a991b06ac..c24a9c72f 100644 --- a/lib/l10n/app_el.arb +++ b/lib/l10n/app_el.arb @@ -559,6 +559,7 @@ "disableInstantPrivacyDesc": "Όλες οι συσκευές θα μπορούν να συνδέονται ελεύθερα στο δίκτυό σας.", "privateMacWarningTitle": "Ορισμένες συσκευές χρησιμοποιούν ιδιωτική διεύθυνση Wi-Fi", "privateMacWarningDesc": "Οι παρακάτω συσκευές χρησιμοποιούν ιδιωτική (τυχαία) διεύθυνση Wi-Fi. Επειδή αυτή η διεύθυνση αλλάζει με την πάροδο του χρόνου, ενδέχεται να αποκλειστούν μετά την εναλλαγή της — ακόμη και αυτή που χρησιμοποιείτε τώρα. Για να παραμείνετε συνδεδεμένοι, απενεργοποιήστε την «Ιδιωτική διεύθυνση Wi-Fi» για αυτές τις συσκευές πριν ενεργοποιήσετε το Instant Privacy.", + "privateMacLabel": "Ιδιωτική", "disableInstantPrivacyTitle": "Απενεργοποίηση Instant Privacy;", "discards": "Απορρίψεις", "distribution": "Κατανομή", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 0197eacef..5ec4edc59 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1401,6 +1401,7 @@ }, "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", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 5203e6707..1bce023a7 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -561,6 +561,7 @@ "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", diff --git a/lib/l10n/app_es_ar.arb b/lib/l10n/app_es_ar.arb index 8a1cec563..535fc597c 100644 --- a/lib/l10n/app_es_ar.arb +++ b/lib/l10n/app_es_ar.arb @@ -561,6 +561,7 @@ "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", diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb index 6a186ddff..020574177 100644 --- a/lib/l10n/app_fi.arb +++ b/lib/l10n/app_fi.arb @@ -559,6 +559,7 @@ "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", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 725e5723c..9f57cdb1a 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -561,6 +561,7 @@ "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", diff --git a/lib/l10n/app_fr_ca.arb b/lib/l10n/app_fr_ca.arb index 5c24493ed..4bb7a17ac 100644 --- a/lib/l10n/app_fr_ca.arb +++ b/lib/l10n/app_fr_ca.arb @@ -561,6 +561,7 @@ "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", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 3198352b3..798270e98 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -559,6 +559,7 @@ "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", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 60c7f7f97..72887c8f0 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -561,6 +561,7 @@ "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", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 46f97f98b..14f461a15 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -562,6 +562,7 @@ "disableInstantPrivacyDesc": "すべてのデバイスがネットワークに自由に接続できるようになります。", "privateMacWarningTitle": "一部のデバイスはプライベート Wi-Fi アドレスを使用しています", "privateMacWarningDesc": "以下のデバイスはプライベート(ランダム化された)Wi-Fi アドレスを使用しています。このアドレスは時間の経過とともに変化するため、切り替わった後にブロックされる可能性があります。現在使用しているデバイスも例外ではありません。接続を維持するには、Instant Privacy を有効にする前に、これらのデバイスの「プライベート Wi-Fi アドレス」をオフにしてください。", + "privateMacLabel": "プライベート", "disableInstantPrivacyTitle": "Instant Privacy を無効にしますか?", "discards": "破棄", "distribution": "分布", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 00eec713a..ccc67da63 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -559,6 +559,7 @@ "disableInstantPrivacyDesc": "모든 장치가 네트워크에 자유롭게 연결할 수 있게 됩니다.", "privateMacWarningTitle": "일부 기기가 비공개 Wi-Fi 주소를 사용합니다", "privateMacWarningDesc": "아래 기기는 비공개(무작위) Wi-Fi 주소를 사용합니다. 이 주소는 시간이 지나면 변경되므로 주소가 바뀐 후에는 차단될 수 있으며, 지금 사용 중인 기기도 예외가 아닙니다. 계속 연결하려면 Instant Privacy를 활성화하기 전에 이 기기들의 “비공개 Wi-Fi 주소”를 꺼 주세요.", + "privateMacLabel": "비공개", "disableInstantPrivacyTitle": "Instant Privacy를 비활성화하시겠습니까?", "discards": "폐기", "distribution": "분포", diff --git a/lib/l10n/app_nb.arb b/lib/l10n/app_nb.arb index 4535e5a82..62299c027 100644 --- a/lib/l10n/app_nb.arb +++ b/lib/l10n/app_nb.arb @@ -561,6 +561,7 @@ "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", diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 9509a77e7..9d45c4fe8 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -561,6 +561,7 @@ "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", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 0e878f335..114f26414 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -559,6 +559,7 @@ "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", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index a45817c17..7a909ca23 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -561,6 +561,7 @@ "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", diff --git a/lib/l10n/app_pt_pt.arb b/lib/l10n/app_pt_pt.arb index 96b892239..e3e78ae38 100644 --- a/lib/l10n/app_pt_pt.arb +++ b/lib/l10n/app_pt_pt.arb @@ -561,6 +561,7 @@ "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", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 664cd8fba..c67d8a31f 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -559,6 +559,7 @@ "disableInstantPrivacyDesc": "Все устройства смогут свободно подключаться к вашей сети.", "privateMacWarningTitle": "Некоторые устройства используют частный адрес Wi-Fi", "privateMacWarningDesc": "Устройства, перечисленные ниже, используют частный (случайный) адрес Wi-Fi. Поскольку этот адрес со временем меняется, после его смены они могут быть заблокированы — даже то, которым вы пользуетесь сейчас. Чтобы не потерять подключение, отключите «Частный адрес Wi-Fi» для этих устройств перед включением Instant Privacy.", + "privateMacLabel": "Частный", "disableInstantPrivacyTitle": "Отключить Instant Privacy?", "discards": "Отброшено", "distribution": "Распределение", diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index d3116ff91..e0ae96865 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -561,6 +561,7 @@ "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", diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 307b02a5e..9e73a5512 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -559,6 +559,7 @@ "disableInstantPrivacyDesc": "อุปกรณ์ทั้งหมดจะสามารถเชื่อมต่อกับเครือข่ายของคุณได้อย่างอิสระ", "privateMacWarningTitle": "อุปกรณ์บางเครื่องใช้ที่อยู่ Wi-Fi ส่วนตัว", "privateMacWarningDesc": "อุปกรณ์ด้านล่างนี้ใช้ที่อยู่ Wi-Fi ส่วนตัว (แบบสุ่ม) เนื่องจากที่อยู่นี้เปลี่ยนแปลงไปตามเวลา อุปกรณ์เหล่านี้อาจถูกบล็อกหลังจากที่อยู่เปลี่ยน — แม้แต่เครื่องที่คุณกำลังใช้อยู่ตอนนี้ เพื่อให้เชื่อมต่ออยู่เสมอ โปรดปิด \"ที่อยู่ Wi-Fi ส่วนตัว\" สำหรับอุปกรณ์เหล่านี้ก่อนเปิดใช้งาน Instant Privacy", + "privateMacLabel": "ส่วนตัว", "disableInstantPrivacyTitle": "ปิดใช้งาน Instant Privacy หรือไม่", "discards": "การละทิ้ง", "distribution": "การกระจาย", diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index 639a5497e..e987d2def 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -559,6 +559,7 @@ "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", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 571e1b081..72548dfcf 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -559,6 +559,7 @@ "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ố", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 069a0bf65..6e056f679 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -562,6 +562,7 @@ "disableInstantPrivacyDesc": "所有设备都将能够自由连接到您的网络。", "privateMacWarningTitle": "部分设备使用私有 Wi-Fi 地址", "privateMacWarningDesc": "以下设备使用私有(随机)Wi-Fi 地址。由于该地址会随时间变化,轮换后这些设备可能会被屏蔽——即使是您现在正在使用的设备。为保持连接,请在启用即时隐私前,关闭这些设备的“私有 Wi-Fi 地址”。", + "privateMacLabel": "私有", "disableInstantPrivacyTitle": "禁用即时隐私?", "discards": "丢弃", "distribution": "分布", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index d31bbbf96..6d7fde67f 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -562,6 +562,7 @@ "disableInstantPrivacyDesc": "所有裝置都將能自由連線至您的網路。", "privateMacWarningTitle": "部分裝置使用私密 Wi-Fi 位址", "privateMacWarningDesc": "以下裝置使用私密(隨機)Wi-Fi 位址。由於該位址會隨時間變更,輪替後這些裝置可能會被封鎖——即使是您目前正在使用的裝置。為維持連線,請在啟用即時隱私前,關閉這些裝置的「私密 Wi-Fi 位址」。", + "privateMacLabel": "私密", "disableInstantPrivacyTitle": "停用即時隱私?", "discards": "捨棄", "distribution": "分布", diff --git a/lib/page/instant_privacy/services/instant_privacy_service.dart b/lib/page/instant_privacy/services/instant_privacy_service.dart index 316c26bcf..81ad6c18f 100644 --- a/lib/page/instant_privacy/services/instant_privacy_service.dart +++ b/lib/page/instant_privacy/services/instant_privacy_service.dart @@ -192,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 5375b21df..6bd098c53 100644 --- a/lib/page/instant_privacy/views/instant_privacy_view.dart +++ b/lib/page/instant_privacy/views/instant_privacy_view.dart @@ -53,12 +53,20 @@ class InstantPrivacyView extends ConsumerWidget { 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(), @@ -190,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( @@ -207,7 +224,7 @@ class InstantPrivacyView extends ConsumerWidget { AppText.bodyMedium(device.displayName), AppText.bodySmall( device.mac, - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: colorScheme.onSurfaceVariant, ), ], ), @@ -221,60 +238,55 @@ class InstantPrivacyView extends ConsumerWidget { // Private (randomized) MAC warning // --------------------------------------------------------------------------- - Widget _buildPrivateMacWarning( - BuildContext context, - List devices, - ) { + /// 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: Container( - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: colorScheme.errorContainer, - borderRadius: BorderRadius.circular(AppSpacing.sm), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AppIcon.font( - Icons.warning_amber_rounded, - size: 20, - color: colorScheme.onErrorContainer, - ), - AppGap.sm(), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AppText.labelLarge( - loc(context).privateMacWarningTitle, - color: colorScheme.onErrorContainer, - ), - AppGap.xs(), - AppText.bodySmall( - loc(context).privateMacWarningDesc, - color: colorScheme.onErrorContainer, - ), - ], - ), - ), - ], + 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, ), - AppGap.sm(), - for (final device in devices) - Padding( - padding: const EdgeInsets.only(top: AppSpacing.xs), - child: AppText.bodySmall( - '• ${device.displayName} (${device.mac})', - color: colorScheme.onErrorContainer, - ), - ), - ], - ), + ), + ], ), ); } @@ -300,7 +312,7 @@ class InstantPrivacyView extends ConsumerWidget { loc(context).enableInstantPrivacyDesc(connected.length), ), if (privateMacDevices.isNotEmpty) - _buildPrivateMacWarning(context, privateMacDevices), + _buildPrivateMacDialogWarning(context), ], ), actions: [ 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 47bf0cf0c..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 @@ -429,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', () { From dd711ec5ecb94e7e93d4ab68f5e1082a67c20a4b Mon Sep 17 00:00:00 2001 From: AustinChangLinksys <79675086+AustinChangLinksys@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:54:32 +0800 Subject: [PATCH 35/56] fix: extract inline scripts to external JS for CSP compliance (#1085) Port #926 fix to dev-2.6.0 branch. Move inline + @@ -105,38 +97,6 @@ right: 0; } - - From b0011652ac122be21ada8f88130f91198e1c6262 Mon Sep 17 00:00:00 2001 From: AustinChangLinksys <79675086+AustinChangLinksys@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:54:56 +0800 Subject: [PATCH 36/56] fix(internet-settings): remove unnecessary DHCP renew result parsing (#1079) DHCP Renew/Renew6 operations return void - no need to parse and validate the result. The previous strict parsing caused false-positive errors on some firmware versions that returned non-standard response formats. Closes #967 Co-authored-by: Claude Opus 4.5 --- .../usp_internet_settings_service.dart | 30 ++----------------- .../usp_internet_settings_service_test.dart | 22 -------------- 2 files changed, 2 insertions(+), 50 deletions(-) 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 0fbfb68c6..fae6c3166 100644 --- a/lib/page/internet_settings/services/usp_internet_settings_service.dart +++ b/lib/page/internet_settings/services/usp_internet_settings_service.dart @@ -558,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); @@ -568,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); @@ -614,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/test/page/internet_settings/services/usp_internet_settings_service_test.dart b/test/page/internet_settings/services/usp_internet_settings_service_test.dart index 9d7a79619..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 @@ -1061,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); From a79c52922e8423f2c7017df9978348226f8318b3 Mon Sep 17 00:00:00 2001 From: AustinChangLinksys <79675086+AustinChangLinksys@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:55:24 +0800 Subject: [PATCH 37/56] fix(static-routing): block interface change without gateway update (#1082) (#1086) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(static-routing): block interface change without gateway update (#1082) When editing a static route, changing the Interface (LAN<->Internet) without also updating the Gateway left the old gateway (belonging to the previously selected interface's subnet) in place, and the form still passed validation and submitted the SET. The backend accepts it, so the route saved successfully instead of being blocked client-side. validateRoute() now takes optional interfaceName / originalInterfaceName / originalGateway; in edit mode, if the interface changed but the gateway is unchanged it returns a gateway error. Add mode (no original values) skips the check. Invalid-gateway-format still takes precedence. Refs #1082 * fix(static-routing): validate gateway subnet based on interface selection (#1082) Replace the "interface changed but gateway unchanged" check with proper subnet validation using existing HostValidForGivenRouterIPAddressAndSubnetMaskRule: - LAN interface: gateway must be within LAN subnet - Internet interface: gateway must be outside LAN subnet Also triggers validation on interface toggle so error message displays immediately. Refs #1082 Co-Authored-By: Claude Opus 4.5 * fix(static-routing): optimize validation and add l10n support (#1082) Address PR review feedback: - W-2: Change _isFormValid to use cached _errors instead of recomputing - S-2: Add l10n keys for gateway subnet validation errors (26 locales) Also initialize _errors in initState to ensure correct initial form state. Refs #1082 Co-Authored-By: Claude Opus 4.5 * fix(static-routing): guard empty-string LAN data in gateway subnet validation W-NEW-1: validateRoute() only null-checked lanIp/lanSubnetMask, but LanData.empty() returns ipAddress:'' / subnetMask:'' (empty strings, not null). ipToNum('') == 0, so HostValidForGivenRouterIPAddressAndSubnetMaskRule computed hostSubnet == routerSubnet == 0 and reported isInLan == true for ANY gateway, firing a permanent false-positive gatewayMustBeOutsideLanSubnet error on Internet-interface routes while LAN data was still loading (device boot / network reset). Guard now also requires isNotEmpty + valid IP/mask before running the subnet rule. Added a regression test covering the empty-string LanData.empty() fallback. Refs #1086 * fix(static-routing): suppress validation errors on add-dialog open (#1082) W-NEW-A: initState called _computeErrors() unconditionally, so opening the add-route dialog showed nameRequired/destIpRequired/subnetMaskRequired errors before the user typed anything. Compute errors only in edit mode; keep the empty-map default on add. * fix(static-routing): enforce form validity and await LAN data (#1082 review-fix) - _isFormValid now recomputes from live field values instead of the cached _errors map, so a blank add-mode form no longer enables Save (N-NEW-1). - _showAddDialog/_showEditDialog await lanDataProvider.future instead of reading valueOrNull, so subnet validation is not silently bypassed on a fast tap while LAN data is still loading (W-NEW-2). * fix(static-routing): catch lanDataProvider errors in add/edit dialog openers (#1082 review-fix) W-R3-1: _showAddDialog/_showEditDialog awaited lanDataProvider.future with no try/catch. When the provider is in AsyncError (router offline, USP timeout, auth failure) the throw propagated through the onTap lambda and Flutter swallowed it silently — tapping Add/Edit did nothing with no error shown. Now both openers surface the error via showFailedSnackBar + localizeServiceError and return early, matching the established _onSave pattern. --------- Co-authored-by: Claude Opus 4.5 --- lib/l10n/app_ar.arb | 2 + lib/l10n/app_da.arb | 2 + lib/l10n/app_de.arb | 2 + lib/l10n/app_el.arb | 2 + lib/l10n/app_en.arb | 2 + lib/l10n/app_es.arb | 2 + lib/l10n/app_es_ar.arb | 2 + lib/l10n/app_fi.arb | 2 + lib/l10n/app_fr.arb | 2 + lib/l10n/app_fr_ca.arb | 2 + lib/l10n/app_id.arb | 2 + lib/l10n/app_it.arb | 2 + lib/l10n/app_ja.arb | 2 + lib/l10n/app_ko.arb | 2 + lib/l10n/app_nb.arb | 2 + lib/l10n/app_nl.arb | 2 + lib/l10n/app_pl.arb | 2 + lib/l10n/app_pt.arb | 2 + lib/l10n/app_pt_pt.arb | 2 + lib/l10n/app_ru.arb | 2 + lib/l10n/app_sv.arb | 2 + lib/l10n/app_th.arb | 2 + lib/l10n/app_tr.arb | 2 + lib/l10n/app_vi.arb | 2 + lib/l10n/app_zh.arb | 2 + lib/l10n/app_zh_TW.arb | 2 + .../services/usp_static_routing_service.dart | 55 +++++++-- .../views/dialogs/static_route_dialog.dart | 79 ++++++++---- .../views/usp_static_routing_view.dart | 42 ++++++- .../usp_static_routing_service_test.dart | 114 ++++++++++++++++-- 30 files changed, 305 insertions(+), 37 deletions(-) diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index d80ba5e3f..fa98e12da 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 غير صالح.", diff --git a/lib/l10n/app_da.arb b/lib/l10n/app_da.arb index e5c49c754..9b2763668 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.", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index e4e2dcf58..760b0f14d 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.", diff --git a/lib/l10n/app_el.arb b/lib/l10n/app_el.arb index c24a9c72f..e8d9b3b23 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.", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 5ec4edc59..e79f06a02 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.", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 1bce023a7..f4ba1cbee 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.", diff --git a/lib/l10n/app_es_ar.arb b/lib/l10n/app_es_ar.arb index 535fc597c..7290018f8 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.", diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb index 020574177..f9e49fd9a 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.", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 9f57cdb1a..66bf6ea01 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.", diff --git a/lib/l10n/app_fr_ca.arb b/lib/l10n/app_fr_ca.arb index 4bb7a17ac..ee13041fe 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.", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 798270e98..1ea2c28c1 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.", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 72887c8f0..c63683861 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.", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 14f461a15..1d45d4b83 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 アドレスです。", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index ccc67da63..b9ab642bd 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 주소입니다.", diff --git a/lib/l10n/app_nb.arb b/lib/l10n/app_nb.arb index 62299c027..ab275573f 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.", diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 9d45c4fe8..e2f012d30 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.", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 114f26414..5e661d4f0 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.", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 7a909ca23..92547097e 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.", diff --git a/lib/l10n/app_pt_pt.arb b/lib/l10n/app_pt_pt.arb index e3e78ae38..148c59e10 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.", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index c67d8a31f..1de666e59 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-адрес.", diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index e0ae96865..1a7fb4ee9 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.", diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 9e73a5512..682897ac2 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 ไม่ถูกต้อง", diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index e987d2def..b1a97e1ea 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.", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 72548dfcf..0137d869c 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ệ.", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 6e056f679..1dcb5955a 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地址。", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 6d7fde67f..c02ffd8b8 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 位址不正確。", 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 bd4549252..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'; @@ -190,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( @@ -209,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/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); + }); }); // --------------------------------------------------------------------------- From 8a5216ee17548e18ec0a53d524e2e93abdf33016 Mon Sep 17 00:00:00 2001 From: AustinChangLinksys <79675086+AustinChangLinksys@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:55:57 +0800 Subject: [PATCH 38/56] fix(dhcp): use Hosts.Active for online status indicator (#1034) (#1087) * fix(dhcp): use Hosts.Active for online status indicator (#1034) DHCP client indicator was showing green for offline devices because it used DHCPv4.Client.Active (lease validity) instead of Hosts.Host.Active (actual device connectivity). Changes: - Add isOnline field to DhcpClientUIModel from Hosts.Active - Rename active to leaseActive for semantic clarity - Dashboard DHCP card now filters to online clients only - DHCP Detail page shows all clients with filter chips (All/Online) - Normalize MAC to uppercase in model constructors - Listen to devicesDataProvider for real-time online status updates Co-Authored-By: Claude Opus 4.5 * fix(dhcp): address PR review feedback - W-1: Fix DhcpData.props to use full list comparison instead of lengths - W-3: Replace Material FilterChip with UI Kit AppChipGroup - W-4: Move filter state to StateProvider for persistence across navigation - W-5: Make isOnline null handling explicit in PDF export - S-1: Use null coalescing for isOnline in AI command provider Co-Authored-By: Claude Opus 4.5 * fix(dhcp): narrow devicesDataProvider listener to online-status changes W-6: the devicesDataProvider listener re-fetched DHCP on ANY DevicesData change (RSSI/band/SSID), and read a possibly-stale snapshot mid-rebuild. Now compare the mac->isActive online-status map (via MapEquality, since Dart Map == is identity-based) and only debounce-invalidate when it actually changed. Refs #1034 * fix(dhcp): use clientDevices (exclude mesh nodes) for online-status diff Mesh nodes (master/slave) never hold DHCP leases, so their MACs cannot appear in DHCP client models. Diffing over deviceModels caused a satellite node going on/offline to falsely fire _debouncedInvalidate() and trigger a redundant DHCP re-fetch. Switch both the devicesDataProvider listener diff and the _fetch() isOnlineByMac map to clientDevices, which excludes mesh nodes. Addresses PR #1087 review finding W-NEW-4. --------- Co-authored-by: Claude Opus 4.5 --- lib/ai/providers/usp_command_provider.dart | 3 +- .../_shared/models/dhcp_client_ui_model.dart | 32 ++++-- .../models/dhcp_reservation_ui_model.dart | 9 +- .../_shared/services/usp_pdf_service.dart | 4 +- .../dhcp_client_filter_provider.dart | 9 ++ .../usp_dhcp_active_leases_card.dart | 100 +++++++++++++----- .../cards/usp_dhcp_reservations_card.dart | 17 +-- .../providers/dhcp_data_provider.dart | 39 +++++-- .../services/usp_dhcp_data_service.dart | 23 ++-- .../cards/fixtures/cards_test_data.dart | 11 +- .../devices/fixtures/devices_test_data.dart | 2 +- .../page/dhcp/fixtures/dhcp_test_data.dart | 23 ++-- .../device_detail_provider_test.dart | 6 +- .../providers/dhcp_data_provider_test.dart | 58 +++++++++- 14 files changed, 251 insertions(+), 85 deletions(-) create mode 100644 lib/page/dhcp/providers/dhcp_client_filter_provider.dart diff --git a/lib/ai/providers/usp_command_provider.dart b/lib/ai/providers/usp_command_provider.dart index d11f26e77..855d84c1d 100644 --- a/lib/ai/providers/usp_command_provider.dart +++ b/lib/ai/providers/usp_command_provider.dart @@ -504,7 +504,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, }) diff --git a/lib/page/_shared/models/dhcp_client_ui_model.dart b/lib/page/_shared/models/dhcp_client_ui_model.dart index 8472f6f0c..6a83ab83a 100644 --- a/lib/page/_shared/models/dhcp_client_ui_model.dart +++ b/lib/page/_shared/models/dhcp_client_ui_model.dart @@ -1,20 +1,35 @@ import 'package:equatable/equatable.dart'; -/// Presentation Layer Model for an active DHCP client lease. +/// 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 { + /// 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 +42,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 +69,6 @@ class DhcpClientUIModel extends Equatable { String get displayName => hostName.isNotEmpty ? hostName : mac; @override - List get props => [mac, ip, active, hostName, leaseExpiry]; + List get props => + [mac, ip, leaseActive, isOnline, hostName, 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..a885236ac 100644 --- a/lib/page/_shared/models/dhcp_reservation_ui_model.dart +++ b/lib/page/_shared/models/dhcp_reservation_ui_model.dart @@ -6,16 +6,19 @@ import 'package:equatable/equatable.dart'; /// that have not yet been saved to the device. class DhcpReservationUIModel extends Equatable { 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, diff --git a/lib/page/_shared/services/usp_pdf_service.dart b/lib/page/_shared/services/usp_pdf_service.dart index 90637ff38..d65a3f4f1 100644 --- a/lib/page/_shared/services/usp_pdf_service.dart +++ b/lib/page/_shared/services/usp_pdf_service.dart @@ -423,13 +423,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(), 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/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/local_network/cards/usp_dhcp_reservations_card.dart b/lib/page/local_network/cards/usp_dhcp_reservations_card.dart index 96ee8b06d..61a50e87a 100644 --- a/lib/page/local_network/cards/usp_dhcp_reservations_card.dart +++ b/lib/page/local_network/cards/usp_dhcp_reservations_card.dart @@ -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(), ], ], ), @@ -104,7 +105,7 @@ class UspDhcpReservationsCard extends ConsumerWidget { height: 8, decoration: BoxDecoration( shape: BoxShape.circle, - color: client.active + color: client.isOnline == true ? (appColors?.semanticSuccess ?? Colors.green) : colorScheme.outline, ), diff --git a/lib/page/local_network/providers/dhcp_data_provider.dart b/lib/page/local_network/providers/dhcp_data_provider.dart index 06c724b41..61ee397ae 100644 --- a/lib/page/local_network/providers/dhcp_data_provider.dart +++ b/lib/page/local_network/providers/dhcp_data_provider.dart @@ -1,5 +1,6 @@ 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'; @@ -21,10 +22,7 @@ class DhcpData extends Equatable { }); @override - List get props => [ - clientModels.length, - reservationModels.length, - ]; + List get props => [clientModels, reservationModels]; } // ── Provider ── @@ -46,6 +44,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,11 +70,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); + // 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, + ); logger.d('[USP][DhcpData]: Fetched — ' 'clients: ${result.clientModels.length}, ' 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/test/golden_test/page/dashboard/cards/fixtures/cards_test_data.dart b/test/golden_test/page/dashboard/cards/fixtures/cards_test_data.dart index 217a04c98..647a0be79 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 @@ -294,7 +294,7 @@ final testDevicesEmptyData = DevicesData( // 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 +319,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), ), 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..3ac9b34d8 100644 --- a/test/golden_test/page/devices/fixtures/devices_test_data.dart +++ b/test/golden_test/page/devices/fixtures/devices_test_data.dart @@ -80,7 +80,7 @@ const wifiDevicePoor = DeviceUIModel( 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', 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/page/devices/providers/device_detail_provider_test.dart b/test/page/devices/providers/device_detail_provider_test.dart index 7ff5e6d50..d686604f7 100644 --- a/test/page/devices/providers/device_detail_provider_test.dart +++ b/test/page/devices/providers/device_detail_provider_test.dart @@ -28,7 +28,7 @@ void main() { isWifi: false, ); - 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', @@ -39,8 +39,8 @@ void main() { deviceModels: [wifiDevice, ethernetDevice], ); - const dhcpData = DhcpData( - clientModels: [], + final dhcpData = DhcpData( + clientModels: const [], reservationModels: [reservation], ); 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..dfbd35384 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,7 @@ 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/device_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'; @@ -107,8 +108,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'); @@ -134,6 +135,55 @@ void main() { container.dispose(); }); + test('isOnline enrichment from devicesData deviceModels', () async { + final container = createContainer( + devicesData: const DevicesData( + hostNameByMac: {'AA:BB:CC:DD:EE:01': 'MyLaptop'}, + deviceModels: [ + DeviceUIModel( + mac: 'AA:BB:CC:DD:EE:01', + ip: '192.168.1.101', + hostName: 'MyLaptop', + isActive: true, + isWifi: true, + ), + DeviceUIModel( + mac: 'AA:BB:CC:DD:EE:02', + ip: '192.168.1.102', + hostName: '', + isActive: false, + isWifi: false, + ), + ], + ), + ); + 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 { + // Empty deviceModels - no Hosts data available + final container = createContainer( + devicesData: const DevicesData( + hostNameByMac: {}, + deviceModels: [], + ), + ); + 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: [ @@ -167,13 +217,13 @@ void main() { container.dispose(); }); - test('DhcpData props uses lengths for equality', () async { + test('DhcpData props uses full lists for equality', () 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.props, [data1.clientModels, data1.reservationModels]); container.dispose(); }); From 418e4f0e8e34723404501f10d1c940ebcde8133a Mon Sep 17 00:00:00 2001 From: AustinChangLinksys <79675086+AustinChangLinksys@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:56:49 +0800 Subject: [PATCH 39/56] fix(nav): use pushNamed for sub-page navigation to preserve back stack (#1093) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(nav): use pushNamed for sub-page navigation to preserve back stack (#1029) Replace goNamed with pushNamed when navigating from list views to detail pages. goNamed rebuilds the route stack declaratively, losing the push history from Dashboard cards. pushNamed preserves the navigation stack so back button correctly returns to the previous page. Co-Authored-By: Claude Opus 4.5 * fix(nav): revert firewall and local_network changes per code review Revert to goNamed for: - Firewall → IPv6PortService: sibling routes under AdvancedSettings, pushNamed + destination's goNamed back causes stack corruption - LocalNetwork → DhcpDetail: pushNamed bypasses onExit dirty-check guard Keep pushNamed for: - DeviceList → DeviceDetail: parent-child route, preserves back stack - NodeDetail → DeviceDetail: cross-section navigation, works correctly Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- lib/page/devices/views/usp_device_list_view.dart | 2 +- lib/page/topology/views/usp_node_detail_view.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/page/devices/views/usp_device_list_view.dart b/lib/page/devices/views/usp_device_list_view.dart index 12fe57340..53bd42875 100644 --- a/lib/page/devices/views/usp_device_list_view.dart +++ b/lib/page/devices/views/usp_device_list_view.dart @@ -121,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/topology/views/usp_node_detail_view.dart b/lib/page/topology/views/usp_node_detail_view.dart index dbdc07f28..de0344ea4 100644 --- a/lib/page/topology/views/usp_node_detail_view.dart +++ b/lib/page/topology/views/usp_node_detail_view.dart @@ -500,7 +500,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}, ), From 905907a445a7a2e484dcc45ae2b092a6d843049b Mon Sep 17 00:00:00 2001 From: AustinChangLinksys <79675086+AustinChangLinksys@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:57:23 +0800 Subject: [PATCH 40/56] fix(port-forwarding): persist forwarded-port edits in port triggering (#1061) (#1101) Editing the "Forwarded Ports" value of an existing port-triggering rule silently reverted. Two independent defects: 1. UI (usp_port_triggering_tab.dart _showEditDialog): the copyWith() that built the edited rule omitted `forwardRules`, so copyWith retained the old forward rules and the dialog's forwarded-port edit was discarded before it ever reached the notifier. 2. Service (usp_port_forwarding_service.dart saveTriggeringBatch): the update path only patched parent-level trigger fields (Enable/Description/ Port/PortEndRange/Protocol) and never reconciled the nested forwarded-port sub-table (Device.NAT.PortTrigger.{i}.Rule.{j}). Even a correct model change would not have been written. Fix: - Dialog now rebuilds the first forward rule from the dialog result, preserving its instancePath for in-place update, and carries any extra forward rules (beyond what the single-mapping dialog shows) untouched. - saveTriggeringBatch now reconciles forward rules for existing parents via a new _reconcileForwardRules helper: in-place Set for changed rules, Add for new (null-instancePath) rules, Delete for removed rules. Tests: 4 new regression tests in usp_port_forwarding_service_test.dart covering edit/add/delete of forward rules on an existing trigger and the no-op case. 50/50 port_forwarding tests pass; dart format + analyze clean. Refs #1061 --- .../services/usp_port_forwarding_service.dart | 133 +++++++++++- .../components/usp_port_triggering_tab.dart | 17 ++ .../usp_port_forwarding_service_test.dart | 189 ++++++++++++++++++ 3 files changed, 337 insertions(+), 2 deletions(-) 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_triggering_tab.dart b/lib/page/port_forwarding/views/components/usp_port_triggering_tab.dart index 6a45a6add..f7a7e3768 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 @@ -133,6 +133,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/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( From aa59621dbc376ce753c97ee76a61bc7f71fc8fa8 Mon Sep 17 00:00:00 2001 From: AustinChangLinksys <79675086+AustinChangLinksys@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:57:44 +0800 Subject: [PATCH 41/56] feat(devices): refactor filter to multi-select chips + add device type and private MAC filters (#1107) (#1110) - Replace single-select dropdowns with multi-select chips (OR within dimension, AND across) - Add Device Type filter with category icons (phone, tablet, computer, etc.) - Add Private/Public MAC filter using OUI lookup - Fix signal filter bug: exclude WiFi with known RSSI when only unknown selected - Add private MAC badge overlay to device list tile icons - Localize 'Private'/'Public' labels across all 26 language files - Bump ui_kit_library to v2.27.0 Co-authored-by: Claude Opus 4.5 --- lib/l10n/app_ar.arb | 2 + lib/l10n/app_da.arb | 2 + lib/l10n/app_de.arb | 2 + lib/l10n/app_el.arb | 2 + lib/l10n/app_en.arb | 2 + lib/l10n/app_es.arb | 2 + lib/l10n/app_es_ar.arb | 2 + lib/l10n/app_fi.arb | 2 + lib/l10n/app_fr.arb | 2 + lib/l10n/app_fr_ca.arb | 2 + lib/l10n/app_id.arb | 2 + lib/l10n/app_it.arb | 2 + lib/l10n/app_ja.arb | 2 + lib/l10n/app_ko.arb | 2 + lib/l10n/app_nb.arb | 2 + lib/l10n/app_nl.arb | 2 + lib/l10n/app_pl.arb | 2 + lib/l10n/app_pt.arb | 2 + lib/l10n/app_pt_pt.arb | 2 + lib/l10n/app_ru.arb | 2 + lib/l10n/app_sv.arb | 2 + lib/l10n/app_th.arb | 2 + lib/l10n/app_tr.arb | 2 + lib/l10n/app_vi.arb | 2 + lib/l10n/app_zh.arb | 2 + lib/l10n/app_zh_TW.arb | 2 + .../providers/device_filter_provider.dart | 325 ++++--- .../providers/device_filter_state.dart | 123 +-- .../components/device_icon_with_badge.dart | 115 ++- .../components/usp_device_filter_panel.dart | 813 +++++++++++------- .../components/usp_device_list_tile.dart | 2 + .../usp_signal_strength_indicator.dart | 68 +- pubspec.yaml | 4 +- .../device_filter_provider_test.dart | 541 ++++++++++-- .../providers/device_filter_state_test.dart | 178 +++- 35 files changed, 1629 insertions(+), 592 deletions(-) diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index fa98e12da..02258983b 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -242,9 +242,11 @@ "prefix": "بادئة", "prefixLength": "طول البادئة", "print": "طباعة", + "privateMac": "خاص", "privacyAndSecurity": "الخصوصية والأمان", "processing": "معالجة...", "protocol": "البروتوكول", + "publicMac": "عام", "quickSetup": "إعداد سريع", "applyToAllBandsDesc": "تطبيق نفس إعدادات WiFi على جميع النطاقات في وقت واحد", "rebootChildTitle": "إعادة تشغيل العقدة وجميع العقد التابعة لها", diff --git a/lib/l10n/app_da.arb b/lib/l10n/app_da.arb index 9b2763668..f8f0a30f1 100644 --- a/lib/l10n/app_da.arb +++ b/lib/l10n/app_da.arb @@ -241,9 +241,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", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 760b0f14d..5c22df229 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -241,9 +241,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", diff --git a/lib/l10n/app_el.arb b/lib/l10n/app_el.arb index e8d9b3b23..be028c28f 100644 --- a/lib/l10n/app_el.arb +++ b/lib/l10n/app_el.arb @@ -239,9 +239,11 @@ "prefix": "πρόθεμα", "prefixLength": "μήκος προθέματος", "print": "Εκτύπωση", + "privateMac": "Ιδιωτική", "privacyAndSecurity": "Απόρρητο & ασφάλεια", "processing": "Επεξεργασία...", "protocol": "Πρωτόκολλο", + "publicMac": "Δημόσια", "quickSetup": "Γρήγορη ρύθμιση", "applyToAllBandsDesc": "Εφαρμόστε τις ίδιες ρυθμίσεις WiFi σε όλες τις ζώνες ταυτόχρονα", "rebootChildTitle": "Επανεκκίνηση του κόμβου και όλων των θυγατρικών του", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index e79f06a02..55f9d1e50 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -396,9 +396,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", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index f4ba1cbee..34b266913 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -241,9 +241,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", diff --git a/lib/l10n/app_es_ar.arb b/lib/l10n/app_es_ar.arb index 7290018f8..d639fca4f 100644 --- a/lib/l10n/app_es_ar.arb +++ b/lib/l10n/app_es_ar.arb @@ -241,9 +241,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", diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb index f9e49fd9a..d72cfe01e 100644 --- a/lib/l10n/app_fi.arb +++ b/lib/l10n/app_fi.arb @@ -239,9 +239,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", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 66bf6ea01..d3fcbebfb 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -241,9 +241,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", diff --git a/lib/l10n/app_fr_ca.arb b/lib/l10n/app_fr_ca.arb index ee13041fe..cdf5b70ef 100644 --- a/lib/l10n/app_fr_ca.arb +++ b/lib/l10n/app_fr_ca.arb @@ -241,9 +241,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", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 1ea2c28c1..ca37678c1 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -239,9 +239,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", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index c63683861..7efb9304a 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -241,9 +241,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", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 1d45d4b83..61f6af9b1 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -242,9 +242,11 @@ "prefix": "プレフィックス", "prefixLength": "プレフィックスの長さ", "print": "印刷", + "privateMac": "プライベート", "privacyAndSecurity": "プライバシーとセキュリティ", "processing": "処理中...", "protocol": "プロトコル", + "publicMac": "パブリック", "quickSetup": "クイック設定", "applyToAllBandsDesc": "同じ WiFi 設定をすべてのバンドに一度に適用する", "rebootChildTitle": "ノードとそのすべての子ノードを再起動", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index b9ab642bd..60fc2d71a 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -239,9 +239,11 @@ "prefix": "접두사", "prefixLength": "접두사 길이", "print": "인쇄", + "privateMac": "비공개", "privacyAndSecurity": "개인정보 보호 및 보안", "processing": "처리 중...", "protocol": "프로토콜", + "publicMac": "공개", "quickSetup": "빠른 설정", "applyToAllBandsDesc": "동일한 WiFi 설정을 모든 대역에 한 번에 적용", "rebootChildTitle": "노드 및 모든 하위 노드 재부팅", diff --git a/lib/l10n/app_nb.arb b/lib/l10n/app_nb.arb index ab275573f..beb9d1d40 100644 --- a/lib/l10n/app_nb.arb +++ b/lib/l10n/app_nb.arb @@ -241,9 +241,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", diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index e2f012d30..a6f98232f 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -241,9 +241,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", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 5e661d4f0..e3e52b671 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -239,9 +239,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", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 92547097e..02324039a 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -241,9 +241,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", diff --git a/lib/l10n/app_pt_pt.arb b/lib/l10n/app_pt_pt.arb index 148c59e10..d63e106bf 100644 --- a/lib/l10n/app_pt_pt.arb +++ b/lib/l10n/app_pt_pt.arb @@ -241,9 +241,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", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 1de666e59..7739a3ac0 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -239,9 +239,11 @@ "prefix": "Префикс", "prefixLength": "Длина префикса", "print": "Печать", + "privateMac": "Частный", "privacyAndSecurity": "Конфиденциальность и безопасность", "processing": "Обработка...", "protocol": "Протокол", + "publicMac": "Публичный", "quickSetup": "Быстрая настройка", "applyToAllBandsDesc": "Применить одинаковые настройки WiFi ко всем диапазонам сразу", "rebootChildTitle": "Перезагрузить узел и все его дочерние узлы", diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index 1a7fb4ee9..f15194662 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -241,9 +241,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", diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 682897ac2..bffdce50b 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -239,9 +239,11 @@ "prefix": "คำนำหน้า", "prefixLength": "ความยาวคำนำหน้า", "print": "พิมพ์", + "privateMac": "ส่วนตัว", "privacyAndSecurity": "ความเป็นส่วนตัวและความปลอดภัย", "processing": "กำลังประมวลผล...", "protocol": "โปรโตคอล", + "publicMac": "สาธารณะ", "quickSetup": "การตั้งค่าด่วน", "applyToAllBandsDesc": "ใช้การตั้งค่า WiFi เดียวกันกับทุกย่านความถี่พร้อมกัน", "rebootChildTitle": "รีบูตโหนดและโหนดลูกทั้งหมด", diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index b1a97e1ea..c56a76cef 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -239,9 +239,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", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 0137d869c..21256d6a7 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -239,9 +239,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", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 1dcb5955a..b64e6b725 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -242,9 +242,11 @@ "prefix": "前缀", "prefixLength": "前缀长度", "print": "打印", + "privateMac": "私有", "privacyAndSecurity": "隐私与安全", "processing": "处理中...", "protocol": "协议", + "publicMac": "公开", "quickSetup": "快速设置", "applyToAllBandsDesc": "同时将相同的 WiFi 设置应用到所有频段", "rebootChildTitle": "重启节点及其所有子节点", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index c02ffd8b8..e2293023e 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -242,9 +242,11 @@ "prefix": "前綴", "prefixLength": "前綴長度", "print": "列印", + "privateMac": "私人", "privacyAndSecurity": "隱私與安全", "processing": "處理中...", "protocol": "通訊協定", + "publicMac": "公開", "quickSetup": "快速設定", "applyToAllBandsDesc": "同時將相同的 WiFi 設定應用到所有頻段", "rebootChildTitle": "重新啟動節點及其所有子節點", diff --git a/lib/page/devices/providers/device_filter_provider.dart b/lib/page/devices/providers/device_filter_provider.dart index 2d08f6a81..c968beea6 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/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(DeviceConnectionType.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(DeviceConnectionType 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,12 +243,16 @@ 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 data = ref.watch(devicesDataProvider).valueOrNull; if (data == null) return []; @@ -177,7 +261,7 @@ final filteredDeviceListProvider = Provider>((ref) { }); bool _matches(DeviceUIModel device, DeviceFilterConfig filter) { - // Status. + // Status if (filter.status == DeviceStatusFilter.online && !device.isActive) { return false; } @@ -185,47 +269,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 ? DeviceConnectionType.wifi : DeviceConnectionType.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(DeviceConnectionType.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..1737bfaae 100644 --- a/lib/page/devices/providers/device_filter_state.dart +++ b/lib/page/devices/providers/device_filter_state.dart @@ -1,80 +1,99 @@ import 'package:equatable/equatable.dart'; +import 'package:privacy_gui/page/_shared/models/device_ui_model.dart'; +import 'package:privacy_gui/page/_shared/utils/device_classifier.dart'; import 'package:privacy_gui/page/topology/models/node_ui_model.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(DeviceConnectionType.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 +101,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 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/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..8e0646973 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/device_ui_model.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'; -/// 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(DeviceConnectionType.wifi)) indices.add(0); + if (types.contains(DeviceConnectionType.wired)) indices.add(1); + return indices; +} + +Set _indicesToConnections(Set indices) { + final types = {}; + if (indices.contains(0)) types.add(DeviceConnectionType.wifi); + if (indices.contains(1)) types.add(DeviceConnectionType.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 == DeviceConnectionType.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: DeviceConnectionType.values, + selected: filter.connections, + labelOf: (v) => v == DeviceConnectionType.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..bf78a270c 100644 --- a/lib/page/devices/views/components/usp_device_list_tile.dart +++ b/lib/page/devices/views/components/usp_device_list_tile.dart @@ -1,4 +1,5 @@ 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'; @@ -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/pubspec.yaml b/pubspec.yaml index df3534413..69253db6d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -59,11 +59,11 @@ dependencies: ui_kit_library: git: url: https://github.com/linksys/privacyGUI-UI-kit.git - ref: v2.26.1 + ref: v2.27.0 generative_ui: git: url: https://github.com/linksys/privacyGUI-UI-kit.git - ref: v2.26.1 + ref: v2.27.0 path: generative_ui flutter_blue_plus: ^1.4.0 crypto: ^3.0.2 diff --git a/test/page/devices/providers/device_filter_provider_test.dart b/test/page/devices/providers/device_filter_provider_test.dart index ca996bd5f..f6ddd2d41 100644 --- a/test/page/devices/providers/device_filter_provider_test.dart +++ b/test/page/devices/providers/device_filter_provider_test.dart @@ -2,6 +2,7 @@ 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/mesh_topology_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'; @@ -30,8 +31,6 @@ void main() { 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', parentNodeId: 'NODE-01', @@ -66,7 +65,18 @@ void main() { isWifi: true, band: '5GHz', ssidName: 'Home', - // signalStrength intentionally null — firmware state where RSSI is absent. + parentNodeId: 'NODE-01', + ); + + const wifiOnlineFair = DeviceUIModel( + mac: 'AA:AA:AA:AA:AA:06', + ip: '192.168.1.106', + hostName: 'Laptop', + isActive: true, + isWifi: true, + band: '2.4GHz', + ssidName: 'Home', + signalStrength: -75, // fair (-71..-78) parentNodeId: 'NODE-01', ); @@ -76,6 +86,7 @@ void main() { ethernetOnline, wifiGuestGood, wifiOnlineNullRssi, + wifiOnlineFair, ]; const devicesData = DevicesData( @@ -111,7 +122,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 +135,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 +154,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({DeviceConnectionType.wifi}); final filtered = container.read(filteredDeviceListProvider); @@ -161,7 +172,7 @@ void main() { final container = await createReadyContainer(); container .read(deviceFilterConfigProvider.notifier) - .setConnection(DeviceConnectionFilter.ethernet); + .setConnections({DeviceConnectionType.wired}); final filtered = container.read(filteredDeviceListProvider); @@ -169,73 +180,105 @@ 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( + {DeviceConnectionType.wifi, DeviceConnectionType.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. - // 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(wifiGuestGood.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( mac: 'AA:AA:AA:AA:AA:02', ip: '192.168.1.102', hostName: 'iPad', isActive: false, isWifi: true, - // parentNodeId: null — realistic offline state. ); final container = await createReadyContainer( @@ -252,43 +295,139 @@ void main() { ); container .read(deviceFilterConfigProvider.notifier) - .setNodeId('NODE-01'); + .setNodeIds({'NODE-01'}); final filtered = container.read(filteredDeviceListProvider); - // wifiOnlineExcellent on NODE-01, offline passes through. expect(filtered, hasLength(2)); container.dispose(); }); }); - 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( + {DeviceConnectionType.wifi, DeviceConnectionType.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 +478,67 @@ 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({DeviceConnectionType.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({DeviceConnectionType.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, {DeviceConnectionType.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( + {DeviceConnectionType.wifi, DeviceConnectionType.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({DeviceConnectionType.wired}); + notifier.setConnections({}); + + expect(container.read(deviceFilterConfigProvider).ssidNames, isEmpty); container.dispose(); }); @@ -395,7 +548,7 @@ void main() { notifier.setStatus(DeviceStatusFilter.online); notifier.setSearchQuery('iphone'); - notifier.setSsidName('Home'); + notifier.setSsidNames({'Home'}); notifier.clearAll(); final state = container.read(deviceFilterConfigProvider); @@ -410,12 +563,12 @@ 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( @@ -427,13 +580,15 @@ void main() { )); 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; @@ -446,15 +601,16 @@ void main() { )); 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; @@ -467,8 +623,8 @@ void main() { )); await Future.value(); - expect(container.read(deviceFilterConfigProvider).signal, - DeviceSignalFilter.all); + expect(container.read(deviceFilterConfigProvider).includeUnknownSignal, + isFalse); container.dispose(); }); }); @@ -486,7 +642,6 @@ void main() { expect(options.nodes, hasLength(2)); expect(options.ssids, containsAll(['Guest', 'Home'])); expect(options.bands, containsAll(['2.4GHz', '5GHz'])); - // wifiOnlineNullRssi + wifiOfflineHome both have null RSSI. expect(options.hasUnknownSignalDevices, isTrue); container.dispose(); }); @@ -525,19 +680,219 @@ 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: {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: {'Home'}); + expect(config.hasWifiOnlyFilter, isTrue); + }); + + test('hasWifiOnlyFilter returns true when bands is set', () { + final config = DeviceFilterConfig(bands: {'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: {DeviceConnectionType.wired}) + .isEthernetOnly, + isTrue, + ); + expect( + DeviceFilterConfig(connections: { + DeviceConnectionType.wifi, + DeviceConnectionType.wired + }).isEthernetOnly, + isFalse, + ); + expect( + const DeviceFilterConfig().isEthernetOnly, + isFalse, + ); + }); + + test('activeCount counts non-empty dimensions', () { + expect(const DeviceFilterConfig().activeCount, 0); + expect( + DeviceFilterConfig(connections: {DeviceConnectionType.wifi}) + .activeCount, + 1, + ); + expect( + DeviceFilterConfig( + connections: {DeviceConnectionType.wifi}, + signals: {DeviceSignalLevel.excellent}, + ).activeCount, + 2, + ); + }); + + test('activeCount includes deviceCategories and privateMac', () { + expect( + DeviceFilterConfig(deviceCategories: {DeviceCategory.phone}) + .activeCount, + 1, + ); + expect( + const DeviceFilterConfig(privateMac: PrivateMacFilter.privateOnly) + .activeCount, + 1, + ); + expect( + DeviceFilterConfig( + deviceCategories: {DeviceCategory.phone}, + privateMac: PrivateMacFilter.privateOnly, + ).activeCount, + 2, + ); + }); + }); + + // --------------------------------------------------------------------------- + // Device Category filter + // --------------------------------------------------------------------------- + + group('filteredDeviceListProvider Device Category filter', () { + test('filters by device category', () async { + final container = await createReadyContainer(); + container + .read(deviceFilterConfigProvider.notifier) + .setDeviceCategories({DeviceCategory.phone}); + + final filtered = container.read(filteredDeviceListProvider); + + // iPhone and GuestPhone should match phone category + expect(filtered.any((d) => d.hostName == 'iPhone'), isTrue); + expect(filtered.any((d) => d.hostName == 'GuestPhone'), isTrue); + // Desktop should not match + expect(filtered.any((d) => d.hostName == 'Desktop'), isFalse); + container.dispose(); + }); + + test('multi-select device categories uses OR logic', () async { + final container = await createReadyContainer(); + container.read(deviceFilterConfigProvider.notifier).setDeviceCategories({ + DeviceCategory.phone, + DeviceCategory.tablet, + }); + + final filtered = container.read(filteredDeviceListProvider); + + // iPhone, GuestPhone (phone), iPad (tablet) should match + expect(filtered.any((d) => d.hostName == 'iPhone'), isTrue); + expect(filtered.any((d) => d.hostName == 'iPad'), isTrue); + 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 + const privateMacDevice = DeviceUIModel( + mac: '02:00:00:AA:AA:01', // Locally administered (private) + ip: '192.168.1.200', + hostName: 'PrivatePhone', + isActive: true, + isWifi: true, + ); + + // Public MAC: bit 1 of first byte = 0 (OUI registered) + // 0x00 = 00000000, bit 1 = 0 -> public + const publicMacDevice = DeviceUIModel( + mac: '00:11:22:33:44:55', // OUI registered (public) + ip: '192.168.1.201', + hostName: 'PublicPhone', + isActive: true, + isWifi: true, + ); + + test('privateOnly shows only private MAC devices', () async { + final container = await createReadyContainer( + data: const DevicesData( + deviceModels: [privateMacDevice, publicMacDevice], + meshTopology: MeshTopologyInfo(nodes: [], clientToNodeMap: {}), + ), + ); + 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: const DevicesData( + deviceModels: [privateMacDevice, publicMacDevice], + meshTopology: MeshTopologyInfo(nodes: [], clientToNodeMap: {}), + ), + ); + 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: const DevicesData( + deviceModels: [privateMacDevice, publicMacDevice], + meshTopology: MeshTopologyInfo(nodes: [], clientToNodeMap: {}), + ), + ); + // Default is PrivateMacFilter.all, no need to set + + final filtered = container.read(filteredDeviceListProvider); + + expect(filtered, hasLength(2)); + container.dispose(); }); }); } @@ -555,8 +910,6 @@ class _FakeDevicesNotifier extends AsyncNotifier @override Future build() async => _data ?? const DevicesData(); - /// Push a new dataset so that `ref.listen(deviceFilterOptionsProvider)` - /// in the notifier fires reconciliation. void emit(DevicesData next) { _data = next; state = AsyncData(next); diff --git a/test/page/devices/providers/device_filter_state_test.dart b/test/page/devices/providers/device_filter_state_test.dart index 5114a27f0..e015d64cc 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/device_ui_model.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,34 @@ 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: {DeviceConnectionType.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 +64,154 @@ void main() { final updated = config.copyWith( searchQuery: 'test', status: DeviceStatusFilter.offline, - nodeId: () => 'node1', - ssidName: () => 'Guest', - band: () => '2.4GHz', + connections: const {DeviceConnectionType.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 {DeviceConnectionType.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: {DeviceConnectionType.wired}) + .isEthernetOnly, + isTrue, + ); + expect( + const DeviceFilterConfig(connections: { + DeviceConnectionType.wifi, + DeviceConnectionType.wired + }).isEthernetOnly, + isFalse, + ); + expect( + const DeviceFilterConfig(connections: {DeviceConnectionType.wifi}) + .isEthernetOnly, + isFalse, + ); + expect( + const DeviceFilterConfig().isEthernetOnly, + isFalse, + ); + }); + + test('activeCount counts non-empty dimensions correctly', () { + expect(const DeviceFilterConfig().activeCount, 0); + expect( + const DeviceFilterConfig(connections: {DeviceConnectionType.wifi}) + .activeCount, + 1, + ); + expect( + const DeviceFilterConfig( + connections: {DeviceConnectionType.wifi}, + signals: {DeviceSignalLevel.excellent}, + ).activeCount, + 2, + ); + expect( + const DeviceFilterConfig( + status: DeviceStatusFilter.online, + connections: {DeviceConnectionType.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: {DeviceConnectionType.wifi}, + ).activeCountExcludingStatus, + 1, + ); + }); }); group('DeviceFilterOptions', () { @@ -104,6 +221,7 @@ void main() { expect(options.nodes, isEmpty); expect(options.ssids, isEmpty); expect(options.bands, isEmpty); + expect(options.hasUnknownSignalDevices, isFalse); }); }); } From 8c5b7592a682e16b5919b40628ee0ce30dca4550 Mon Sep 17 00:00:00 2001 From: Peter Jhong <52424995+PeterJhongLinksys@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:58:20 +0800 Subject: [PATCH 42/56] fix(local-network): cross-service SET, post-save IP redirect, pool prefix sync (#1039) (#1117) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix firmware error 7005 on save: LAN settings span multiple USP Services (Device.IP, Device.DHCPv4, Device.DeviceInfo), which the firmware cannot apply in one atomic SET. Pass allowPartial: true so each Service applies independently. - Add post-save redirect flow for IP changes: changing the router IP makes the old address unreachable and the SET response never returns, so treat an IP-changing SET as terminal (timeout / transport error = success; a real firmware fault still surfaces), redirect the browser to https://.local, and intentionally drop SSE to suppress the recovery dialog. Triggered only on an IP address change — a mask-only or DHCP-only change is awaited normally with a success message. - Fix address pool prefix sync following IP or subnet mask changes: the pool prefix only re-synced when the IP changed, not the mask. Tightening the mask (e.g. /16 to /24) widened the locked prefix without updating the pool, leaving pool octets both out-of-subnet (validation error) and locked read-only — a dead end. Sync now runs on either change. - Add lanIpRedirect strings across all 26 locales. - Add tests: terminal-SET classification (timeout/transport = success, fault rethrows), redirect + SSE-disconnect branch, and pool prefix auto-sync. Co-authored-by: Claude Opus 4.8 --- lib/l10n/app_ar.arb | 5 +- lib/l10n/app_da.arb | 5 +- lib/l10n/app_de.arb | 5 +- lib/l10n/app_el.arb | 5 +- lib/l10n/app_en.arb | 13 +- lib/l10n/app_es.arb | 5 +- lib/l10n/app_es_ar.arb | 5 +- lib/l10n/app_fi.arb | 5 +- lib/l10n/app_fr.arb | 5 +- lib/l10n/app_fr_ca.arb | 5 +- lib/l10n/app_id.arb | 5 +- lib/l10n/app_it.arb | 5 +- lib/l10n/app_ja.arb | 5 +- lib/l10n/app_ko.arb | 5 +- lib/l10n/app_nb.arb | 5 +- lib/l10n/app_nl.arb | 5 +- lib/l10n/app_pl.arb | 5 +- lib/l10n/app_pt.arb | 5 +- lib/l10n/app_pt_pt.arb | 5 +- lib/l10n/app_ru.arb | 5 +- lib/l10n/app_sv.arb | 5 +- lib/l10n/app_th.arb | 5 +- lib/l10n/app_tr.arb | 5 +- lib/l10n/app_vi.arb | 5 +- lib/l10n/app_zh.arb | 5 +- lib/l10n/app_zh_TW.arb | 5 +- .../models/local_network_feature_state.dart | 8 + .../providers/usp_local_network_notifier.dart | 61 +++++++- .../services/usp_local_network_service.dart | 98 +++++++++--- .../views/helpers/lan_ip_redirect_dialog.dart | 40 +++++ .../views/usp_local_network_view.dart | 24 ++- .../usp_local_network_notifier_test.dart | 136 +++++++++++++++- .../usp_local_network_service_test.dart | 146 ++++++++++++++---- .../helpers/lan_ip_redirect_dialog_test.dart | 70 +++++++++ 34 files changed, 628 insertions(+), 93 deletions(-) create mode 100644 lib/page/local_network/views/helpers/lan_ip_redirect_dialog.dart create mode 100644 test/page/local_network/views/helpers/lan_ip_redirect_dialog_test.dart diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 02258983b..a10093827 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -1129,5 +1129,8 @@ "bridgeReconnectHint": "بعد الحفظ، أعد الاتصال بجهاز التوجيه على {url}.", "bridgeRedirectTitle": "إعادة الاتصال بجهاز التوجيه", "bridgeRedirectMessage": "يعمل جهاز التوجيه الآن كجسر شفاف ولم يعد يوزّع عناوين IP محلية. أعد الاتصال على {url}.", - "bridgeRedirectButton": "الانتقال إلى جهاز التوجيه" + "bridgeRedirectButton": "الانتقال إلى جهاز التوجيه", + "lanIpRedirectTitle": "إعادة الاتصال بجهاز التوجيه", + "lanIpRedirectMessage": "لقد تغيّر عنوان IP الخاص بجهاز التوجيه. أعد الاتصال به على {url}. قد تحتاج إلى الانتظار قليلاً حتى يحصل جهازك على عنوان جديد.", + "lanIpRedirectButton": "الانتقال إلى جهاز التوجيه" } diff --git a/lib/l10n/app_da.arb b/lib/l10n/app_da.arb index f8f0a30f1..3d88bd9b6 100644 --- a/lib/l10n/app_da.arb +++ b/lib/l10n/app_da.arb @@ -1129,5 +1129,8 @@ "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" + "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 5c22df229..20181bb7e 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1129,5 +1129,8 @@ "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" + "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 be028c28f..4481ed2b1 100644 --- a/lib/l10n/app_el.arb +++ b/lib/l10n/app_el.arb @@ -1129,5 +1129,8 @@ "bridgeReconnectHint": "Μετά την αποθήκευση, επανασυνδεθείτε στον δρομολογητή σας στη διεύθυνση {url}.", "bridgeRedirectTitle": "Επανασύνδεση στον δρομολογητή σας", "bridgeRedirectMessage": "Ο δρομολογητής σας λειτουργεί τώρα ως διαφανής γέφυρα και δεν εκχωρεί πλέον τοπικές διευθύνσεις IP. Επανασυνδεθείτε στη διεύθυνση {url}.", - "bridgeRedirectButton": "Μετάβαση στον δρομολογητή" + "bridgeRedirectButton": "Μετάβαση στον δρομολογητή", + "lanIpRedirectTitle": "Επανασύνδεση στον δρομολογητή σας", + "lanIpRedirectMessage": "Η διεύθυνση IP του δρομολογητή σας άλλαξε. Επανασυνδεθείτε σε αυτόν στο {url}. Ίσως χρειαστεί να περιμένετε λίγο μέχρι η συσκευή σας να λάβει νέα διεύθυνση.", + "lanIpRedirectButton": "Μετάβαση στον δρομολογητή" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 55f9d1e50..02cebec46 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1722,5 +1722,16 @@ } } }, - "bridgeRedirectButton": "Go to router" + "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 34b266913..0881c02aa 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1129,5 +1129,8 @@ "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" + "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 d639fca4f..69e7a7793 100644 --- a/lib/l10n/app_es_ar.arb +++ b/lib/l10n/app_es_ar.arb @@ -1129,5 +1129,8 @@ "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" + "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 d72cfe01e..adba4fe86 100644 --- a/lib/l10n/app_fi.arb +++ b/lib/l10n/app_fi.arb @@ -1129,5 +1129,8 @@ "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" + "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 d3fcbebfb..fdd6b1769 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1129,5 +1129,8 @@ "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" + "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 cdf5b70ef..18ceaeffc 100644 --- a/lib/l10n/app_fr_ca.arb +++ b/lib/l10n/app_fr_ca.arb @@ -1129,5 +1129,8 @@ "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" + "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 ca37678c1..40d89f81e 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -1129,5 +1129,8 @@ "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" + "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 7efb9304a..cd35ef6d2 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -1129,5 +1129,8 @@ "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" + "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 61f6af9b1..b644492c4 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -1129,5 +1129,8 @@ "bridgeReconnectHint": "保存後、{url} からルーターに再接続してください。", "bridgeRedirectTitle": "ルーターに再接続", "bridgeRedirectMessage": "ルーターは現在、透過的なブリッジとして動作しており、ローカル IP アドレスを割り当てません。{url} から再接続してください。", - "bridgeRedirectButton": "ルーターに移動" + "bridgeRedirectButton": "ルーターに移動", + "lanIpRedirectTitle": "ルーターに再接続", + "lanIpRedirectMessage": "ルーターの IP アドレスが変更されました。{url} から再接続してください。デバイスが新しいアドレスを取得するまで少し時間がかかる場合があります。", + "lanIpRedirectButton": "ルーターに移動" } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 60fc2d71a..8a2afde43 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -1129,5 +1129,8 @@ "bridgeReconnectHint": "저장한 후 {url} 에서 라우터에 다시 연결하세요.", "bridgeRedirectTitle": "라우터에 다시 연결", "bridgeRedirectMessage": "라우터가 이제 투명 브리지로 작동하며 로컬 IP 주소를 더 이상 할당하지 않습니다. {url} 에서 다시 연결하세요.", - "bridgeRedirectButton": "라우터로 이동" + "bridgeRedirectButton": "라우터로 이동", + "lanIpRedirectTitle": "라우터에 다시 연결", + "lanIpRedirectMessage": "라우터의 IP 주소가 변경되었습니다. {url}에서 다시 연결하세요. 기기가 새 주소를 받을 때까지 잠시 기다려야 할 수 있습니다.", + "lanIpRedirectButton": "라우터로 이동" } diff --git a/lib/l10n/app_nb.arb b/lib/l10n/app_nb.arb index beb9d1d40..65b012118 100644 --- a/lib/l10n/app_nb.arb +++ b/lib/l10n/app_nb.arb @@ -1129,5 +1129,8 @@ "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" + "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 a6f98232f..68db4f384 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -1129,5 +1129,8 @@ "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" + "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 e3e52b671..7b8e9f585 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -1129,5 +1129,8 @@ "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" + "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 02324039a..12e52f974 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -1129,5 +1129,8 @@ "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" + "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 d63e106bf..620606780 100644 --- a/lib/l10n/app_pt_pt.arb +++ b/lib/l10n/app_pt_pt.arb @@ -1129,5 +1129,8 @@ "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" + "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 7739a3ac0..29876fd8b 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -1129,5 +1129,8 @@ "bridgeReconnectHint": "После сохранения снова подключитесь к маршрутизатору по адресу {url}.", "bridgeRedirectTitle": "Повторное подключение к маршрутизатору", "bridgeRedirectMessage": "Теперь ваш маршрутизатор работает как прозрачный мост и больше не назначает локальные IP-адреса. Снова подключитесь по адресу {url}.", - "bridgeRedirectButton": "Перейти к маршрутизатору" + "bridgeRedirectButton": "Перейти к маршрутизатору", + "lanIpRedirectTitle": "Повторное подключение к маршрутизатору", + "lanIpRedirectMessage": "IP-адрес вашего маршрутизатора изменился. Повторно подключитесь к нему по адресу {url}. Возможно, придётся подождать, пока ваше устройство получит новый адрес.", + "lanIpRedirectButton": "Перейти к маршрутизатору" } diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index f15194662..3bc87b05a 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -1129,5 +1129,8 @@ "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" + "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 bffdce50b..c583f1cf4 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -1129,5 +1129,8 @@ "bridgeReconnectHint": "หลังจากบันทึกแล้ว โปรดเชื่อมต่อกับเราเตอร์ของคุณอีกครั้งที่ {url}", "bridgeRedirectTitle": "เชื่อมต่อกับเราเตอร์ของคุณอีกครั้ง", "bridgeRedirectMessage": "ขณะนี้เราเตอร์ของคุณทำงานเป็นบริดจ์แบบโปร่งใสและไม่แจกจ่ายที่อยู่ IP ภายในอีกต่อไป โปรดเชื่อมต่อใหม่ที่ {url}", - "bridgeRedirectButton": "ไปที่เราเตอร์" + "bridgeRedirectButton": "ไปที่เราเตอร์", + "lanIpRedirectTitle": "เชื่อมต่อกับเราเตอร์ของคุณอีกครั้ง", + "lanIpRedirectMessage": "ที่อยู่ IP ของเราเตอร์ของคุณเปลี่ยนแปลงแล้ว เชื่อมต่อกับเราเตอร์อีกครั้งที่ {url} คุณอาจต้องรอสักครู่ให้อุปกรณ์ของคุณได้รับที่อยู่ใหม่", + "lanIpRedirectButton": "ไปที่เราเตอร์" } diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index c56a76cef..b35f65568 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -1129,5 +1129,8 @@ "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" + "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 21256d6a7..6fe35c0c7 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -1129,5 +1129,8 @@ "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" + "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 b64e6b725..76be733ee 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1129,5 +1129,8 @@ "bridgeReconnectHint": "保存后,请改用 {url} 重新连接到您的路由器。", "bridgeRedirectTitle": "重新连接到您的路由器", "bridgeRedirectMessage": "您的路由器现在是透明网桥,不再分配本地 IP 地址。请改用 {url} 重新连接。", - "bridgeRedirectButton": "前往路由器" + "bridgeRedirectButton": "前往路由器", + "lanIpRedirectTitle": "重新连接到您的路由器", + "lanIpRedirectMessage": "您的路由器 IP 地址已更改。请通过 {url} 重新连接。您可能需要稍等片刻,等待设备获取新地址。", + "lanIpRedirectButton": "前往路由器" } diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index e2293023e..747e173ed 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -1129,5 +1129,8 @@ "bridgeReconnectHint": "儲存後,請改用 {url} 重新連線到您的路由器。", "bridgeRedirectTitle": "重新連線到您的路由器", "bridgeRedirectMessage": "您的路由器現在是透明橋接器,不再配發本機 IP 位址。請改用 {url} 重新連線。", - "bridgeRedirectButton": "前往路由器" + "bridgeRedirectButton": "前往路由器", + "lanIpRedirectTitle": "重新連線到您的路由器", + "lanIpRedirectMessage": "您的路由器 IP 位址已變更。請透過 {url} 重新連線。您可能需要稍候片刻,等待裝置取得新位址。", + "lanIpRedirectButton": "前往路由器" } 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/usp_local_network_notifier.dart b/lib/page/local_network/providers/usp_local_network_notifier.dart index f07590b98..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; @@ -148,8 +187,8 @@ class UspLocalNetworkNotifier /// 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; @@ -160,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( 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 9d2c786b4..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'; @@ -407,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; } @@ -418,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/test/page/local_network/providers/usp_local_network_notifier_test.dart b/test/page/local_network/providers/usp_local_network_notifier_test.dart index cbfa33785..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)), ], @@ -233,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_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); + }); +} From 727ed877d5d13ea50f33cc7b2f6c5d6f0b5a700a Mon Sep 17 00:00:00 2001 From: AustinChangLinksys <79675086+AustinChangLinksys@users.noreply.github.com> Date: Mon, 13 Jul 2026 11:57:01 +0800 Subject: [PATCH 43/56] fix(dashboard): exit edit mode when navigating away (#1037) (#1089) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(dashboard): exit edit mode when navigating away (#1037) Extract edit mode state from view-local to a centralized provider so it can be accessed by route guards. When user navigates away from dashboard (e.g., tab switch) during edit mode, onExit cancels edit mode and reverts any unsaved layout changes. Co-Authored-By: Claude Opus 4.5 * fix(dashboard): preserve applied layout changes on edit-mode exit (#1089) Address PR #1089 review. Root cause: exitEditMode({bool save}) was an inverted-boolean trap — the !save branch persisted the pre-edit snapshot, so the settings-panel reset/preset path (which had already applied its change) called save:false and silently reverted the user's change. - Replace exitEditMode({bool save}) with explicit commitEditMode() (keep changes) and cancelEditMode() (revert to snapshot), sharing a private _exitEditMode({required bool revert}) that always resets state in a finally block (no stranded isEditing=true on save/restore failure) - Route _openLayoutSettings reset/preset path to commitEditMode() so the just-applied change is preserved; drop dead 'toggle_off' branch - enterEditMode: add re-entrant guard and claim isEditing before the await so an onExit firing in the async gap can never strand edit mode - route_usp_dashboard onExit: wrap cancelEditMode in try-catch and document the intentional silent-discard policy - Tighten DashboardEditState.layoutSnapshot to List>? - Delete unused lib/route/linksys_route.dart (dead code; active LinksysRoute lives in route_model.dart) - Add regression tests: commit-preserves-changes, re-entrant guard (W-1), cancel-during-async-gap (W-2) Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.5 --- .../dashboard_edit_mode_provider.dart | 111 +++++++++ .../views/usp_sliver_dashboard_view.dart | 79 ++---- lib/route/linksys_route.dart | 51 ---- lib/route/route_usp_dashboard.dart | 26 ++ lib/route/router_provider.dart | 1 + .../dashboard_edit_mode_provider_test.dart | 230 ++++++++++++++++++ 6 files changed, 390 insertions(+), 108 deletions(-) create mode 100644 lib/page/dashboard/providers/dashboard_edit_mode_provider.dart delete mode 100644 lib/route/linksys_route.dart create mode 100644 test/page/dashboard/providers/dashboard_edit_mode_provider_test.dart 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/views/usp_sliver_dashboard_view.dart b/lib/page/dashboard/views/usp_sliver_dashboard_view.dart index f405fd51d..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(); } } 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 365932305..3faf4da6b 100644 --- a/lib/route/route_usp_dashboard.dart +++ b/lib/route/route_usp_dashboard.dart @@ -19,6 +19,32 @@ 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, diff --git a/lib/route/router_provider.dart b/lib/route/router_provider.dart index 667407dd4..28f83e849 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'; 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); + } + }); + }); +} From 30e78fd8e8b15a9c1503e1b438c1586b95cf9582 Mon Sep 17 00:00:00 2001 From: AustinChangLinksys <79675086+AustinChangLinksys@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:28:17 +0800 Subject: [PATCH 44/56] fix(devices): MeshNetwork architecture + fix #1043 #1044 #1047 #1048 (#1068) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(devices): introduce MeshNetwork architecture with SSoT pattern (#1043, #1044) Phase 1-3 of DeviceUIModel/NodeUIModel refactoring: New models (lib/page/_shared/models/): - NetworkEntity: abstract base class for network entities - ClientDevice: client device model with WifiConnectionInfo - NodeEntity: sealed class (MasterNode/SlaveNode) with BackhaulInfo - MeshNetwork: top-level SSoT container with lookup helpers - WifiConnectionInfo/BackhaulInfo: value objects for connection details New builder (lib/page/_shared/utils/): - MeshNetworkBuilder: constructs MeshNetwork from Hosts + DataElements Integration: - DevicesData: added meshNetwork field alongside legacy deviceModels/nodeModels - UspDevicesDataService: builds MeshNetwork in fetch() and rebuild methods - Compatibility layer maintains existing API for gradual migration Also fixes Device Analytics card (#1043, #1044): - Simplified to 4 tabs: Overview, Signal, Trend, Activity - Fixed Y-axis duplicate labels in trend chart - Use node hostname instead of model name for child node clients - Exclude mesh nodes from client distribution counts Co-Authored-By: Claude Opus 4.5 * refactor(topology): migrate to MeshNetwork architecture (Phase 4) - UspTopologyBuilder: add buildFromMeshNetwork() method, deprecate old build() - UspNetworkTopologyCard: use meshNetwork parameter instead of device/node lists - UspTopologyView: use buildFromMeshNetwork() - uspNodeDetailProvider: support NodeEntity with legacy model conversion The provider now uses MeshNetwork.findNode() for direct lookup and pre-organized connectedClients, while maintaining backward compatibility by converting to NodeUIModel/DeviceUIModel for existing views. Co-Authored-By: Claude Opus 4.5 * refactor(devices): complete Phase 5 - remove DeviceUIModel/NodeUIModel (#1043, #1044) Complete migration to MeshNetwork architecture as Single Source of Truth: - Remove DeviceUIModel class and related extensions - Remove NodeUIModel class and legacy conversion helpers - Remove obsolete test files for deleted models - Migrate all 26+ consumers to use ClientDevice/NodeEntity directly - Update MeshNetworkBuilder to patch parentNodeName on all clients - Improve Device Analytics card UI with LayoutBlock grid layout - Show connected node name for ALL devices (including master node clients) Architecture benefits: - SSoT: MeshNetwork contains all network entities in one place - Sealed classes: NodeEntity (MasterNode/SlaveNode) enable pattern matching - Direct ownership: Nodes own their connectedClients list - Cleaner lookups: meshNetwork.findNode()/findClient() replace manual loops Co-Authored-By: Claude Opus 4.5 * style: dart format usp_network_topology_card.dart Co-Authored-By: Claude Opus 4.5 * fix(devices): correct Equatable props and add model tests (#1068) Fix DevicesData.props to use full objects instead of .length for proper Riverpod state comparison. Add comprehensive unit tests for MeshNetwork, ClientDevice, NodeEntity models and UspTopologyBuilder. - Fix props bug: meshTopology/hostNameByMac now compared as objects - Add DevicesTestData builder (484 lines) for centralized test factories - Add MeshNetwork tests (45 tests): accessors, lookups, Equatable - Add ClientDevice tests (44 tests): displayName, WiFi, multi-interface - Add NodeEntity tests (28 tests): Master/Slave, backhaul, extensions - Add UspTopologyBuilder tests (28 tests): nodes, links, edge cases Total: 145 new tests, all 3047 tests passing. Co-Authored-By: Claude Opus 4.5 * feat(wifi): show slave node clients in WiFi Performance card - Add clientBandSsidMap to MeshTopologyInfo for slave client band/SSID - Add buildBssidToBandMap() to resolve BSSID → band via SSID LowerLayers - Use meshNetwork.allClients as data source instead of wifiClientMap - Add band/SSID fallback to clientBandSsidMap for slave node clients - Truncate long client names in Speed tab chart to prevent overlap - Add comprehensive tests for MeshNetworkBuilder, MeshTopologyBuilder, and UspWifiDataService Closes #1043, #1044 Co-Authored-By: Claude Opus 4.5 * fix(devices): address PR #1068 review — RA UUID, wired categorization, fallback Resolve the two blocking review issues plus a rule violation and a band-fallback logic bug found in MeshNetworkBuilder: - RA deviceUUID regression: add hostsDeviceId (Hosts UUID) to MasterNode, plumb it through MeshNetworkBuilder, and use it in deviceCredentialsProvider instead of the MAC. Returns null when no UUID is available (matches the legacy behavior; avoids sending a wrong deviceUUID to Guardian RA). - Wired-client miscategorization: _getDeviceCategory now keys off parentNodeId (null only for master clients) instead of the parentNodeName that the builder patches onto ALL clients — master wired clients bucket under "Wired" again. - Remove dead test/test_helpers/mesh_network_test_helper.dart (unused; duplicates DevicesTestData; violated constitution §1.6.2 location rule). - Band/SSID fallback: treat empty-string band/ssid from connectionDetailMap as absent so the DataElements value is used (empty string no longer masks it). Tests: add device_credentials_provider_test; update notifier test to model the real patched shape (master clients carry parentNodeName) as a regression guard; add empty-string fallback case and MasterNode.hostsDeviceId equality test. Note: slave-node client band display (#1044 follow-up) needs codegen YAML changes and is tracked separately — see doc/usp/dataelements-sta-band-enhancement.md. Co-Authored-By: Claude Opus 4.8 * fix(devices): categorize by band on mesh + guard SNR average (PR #1068 review) Address the two review findings from @PeterJhongLinksys and @HankYuLinksys. - _getDeviceCategory: categorize WiFi clients by band whenever a band is present, regardless of parentNodeId. On a real mesh, MeshTopologyBuilder maps every node's STAs — including the master's own clients — into clientToNodeMap, so master WiFi clients get a non-null parentNodeId and the old `parentNodeId != null` check collapsed their band under the gateway name. Master WiFi band comes from the local WiFi.AccessPoint chain (FW-provided), so band-first is correct; band-less WiFi (slave clients, pending #1118) falls back to the node name; wired → "Wired". - usp_wifi_performance_card: only clients with real noise data (noise != 0) contribute to the per-radio average SNR. Slave clients have noise 0, so including them would deflate the average once #1118 gives them a band; they are still counted in clientsPerRadio. - Update analytics regression test to the real mesh shape (master WiFi client with non-null parentNodeId + band → categorized as band, not gateway name). Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.5 --- lib/ai/providers/usp_command_provider.dart | 18 +- .../device_credentials_provider.dart | 20 +- .../extensions/device_ui_extensions.dart | 10 - lib/page/_shared/models/backhaul_info.dart | 77 + lib/page/_shared/models/client_device.dart | 302 ++++ lib/page/_shared/models/device_ui_model.dart | 314 ---- lib/page/_shared/models/mesh_network.dart | 132 ++ .../_shared/models/mesh_topology_info.dart | 19 +- lib/page/_shared/models/network_entity.dart | 22 + lib/page/_shared/models/node_entity.dart | 321 ++++ lib/page/_shared/models/pdf_report_data.dart | 16 +- .../_shared/models/wifi_connection_info.dart | 73 + .../usp_device_analytics_notifier.dart | 88 +- .../_shared/services/usp_pdf_service.dart | 14 +- .../_shared/utils/mesh_network_builder.dart | 488 ++++++ .../_shared/utils/mesh_topology_builder.dart | 99 +- .../admin/cards/usp_device_info_card.dart | 3 +- .../health/dimensions/devices_dimension.dart | 2 +- .../mascot/mascot_message_provider.dart | 3 +- .../providers/pdf_report_data_provider.dart | 4 +- .../components/usp_device_analytics_card.dart | 413 +++--- .../views/components/usp_stats_panel.dart | 2 +- .../cards/usp_connected_devices_card.dart | 6 +- .../providers/device_detail_provider.dart | 6 +- .../providers/device_filter_provider.dart | 21 +- .../providers/device_filter_state.dart | 13 +- .../providers/devices_data_provider.dart | 98 +- .../services/usp_devices_data_service.dart | 398 +---- .../components/usp_device_filter_panel.dart | 26 +- .../components/usp_device_list_tile.dart | 4 +- .../devices/views/usp_device_detail_view.dart | 60 +- .../providers/firmware_update_notifier.dart | 15 +- lib/page/instant_setup/models/pnp_state.dart | 4 +- .../providers/ethernet_data_provider.dart | 4 +- .../services/usp_ethernet_data_service.dart | 7 +- .../sections/stats_wifi_signal_section.dart | 2 +- .../sections/stats_wifi_speed_section.dart | 2 +- .../cards/usp_network_topology_card.dart | 26 +- .../helpers/usp_topology_builder.dart | 234 ++- lib/page/topology/models/node_ui_model.dart | 134 -- .../providers/node_detail_provider.dart | 70 +- .../topology/views/usp_node_detail_view.dart | 67 +- .../topology/views/usp_topology_view.dart | 9 +- .../cards/usp_wifi_performance_card.dart | 96 +- .../services/usp_wifi_data_service.dart | 32 + .../providers/usp_command_provider_test.dart | 47 +- .../device_credentials_provider_test.dart | 159 ++ .../golden_framework/mocks/mock_devices.dart | 39 +- .../golden_framework/mocks/mock_dhcp.dart | 8 +- .../mocks/mock_ipv6_port_service.dart | 8 +- .../mocks/mock_statistics.dart | 8 +- .../golden_framework/mocks/mock_topology.dart | 10 +- .../cards/fixtures/cards_test_data.dart | 93 +- .../devices/fixtures/devices_test_data.dart | 77 +- .../topology/fixtures/topology_test_data.dart | 168 +-- test/mocks/test_data/devices_test_data.dart | 486 ++++++ .../_shared/models/client_device_test.dart | 525 +++++++ .../_shared/models/device_ui_model_test.dart | 454 ------ .../_shared/models/mesh_network_test.dart | 428 ++++++ .../page/_shared/models/node_entity_test.dart | 368 +++++ .../usp_device_analytics_notifier_test.dart | 302 ++-- .../utils/mesh_network_builder_test.dart | 606 ++++++++ .../utils/mesh_topology_builder_test.dart | 131 +- .../dimensions/devices_dimension_test.dart | 96 +- .../dashboard_domain_ready_provider_test.dart | 14 +- .../device_detail_provider_test.dart | 123 +- .../device_filter_provider_test.dart | 316 ++-- .../providers/device_filter_state_test.dart | 28 +- .../providers/devices_data_provider_test.dart | 86 +- .../usp_devices_data_service_test.dart | 81 +- .../usp_dhcp_reservations_notifier_test.dart | 80 +- .../dhcp_reservation_edit_dialog_test.dart | 14 +- .../providers/dhcp_data_provider_test.dart | 75 +- .../ethernet_data_provider_test.dart | 19 +- .../usp_ethernet_data_service_test.dart | 8 +- .../helpers/usp_topology_builder_test.dart | 1304 ++++++----------- .../topology/models/node_ui_model_test.dart | 311 ---- .../providers/node_detail_provider_test.dart | 201 +-- .../services/usp_wifi_data_service_test.dart | 162 ++ 79 files changed, 6551 insertions(+), 4058 deletions(-) delete mode 100644 lib/page/_shared/extensions/device_ui_extensions.dart create mode 100644 lib/page/_shared/models/backhaul_info.dart create mode 100644 lib/page/_shared/models/client_device.dart delete mode 100644 lib/page/_shared/models/device_ui_model.dart create mode 100644 lib/page/_shared/models/mesh_network.dart create mode 100644 lib/page/_shared/models/network_entity.dart create mode 100644 lib/page/_shared/models/node_entity.dart create mode 100644 lib/page/_shared/models/wifi_connection_info.dart create mode 100644 lib/page/_shared/utils/mesh_network_builder.dart delete mode 100644 lib/page/topology/models/node_ui_model.dart create mode 100644 test/core/cloud/providers/remote_assistance/device_credentials_provider_test.dart create mode 100644 test/mocks/test_data/devices_test_data.dart create mode 100644 test/page/_shared/models/client_device_test.dart delete mode 100644 test/page/_shared/models/device_ui_model_test.dart create mode 100644 test/page/_shared/models/mesh_network_test.dart create mode 100644 test/page/_shared/models/node_entity_test.dart create mode 100644 test/page/_shared/utils/mesh_network_builder_test.dart delete mode 100644 test/page/topology/models/node_ui_model_test.dart diff --git a/lib/ai/providers/usp_command_provider.dart b/lib/ai/providers/usp_command_provider.dart index 855d84c1d..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(); @@ -870,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/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/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..6eac048f6 --- /dev/null +++ b/lib/page/_shared/models/backhaul_info.dart @@ -0,0 +1,77 @@ +import 'package:equatable/equatable.dart'; + +/// Backhaul connection info for slave mesh nodes. +/// +/// Describes how a slave node connects to its parent (WiFi or Ethernet). +class BackhaulInfo with EquatableMixin { + /// 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, + ]; +} diff --git a/lib/page/_shared/models/client_device.dart b/lib/page/_shared/models/client_device.dart new file mode 100644 index 000000000..011d528af --- /dev/null +++ b/lib/page/_shared/models/client_device.dart @@ -0,0 +1,302 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter/material.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 { + /// 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, + ]; +} + +/// 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 { + // ─── 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, + ]; +} + +/// 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/mesh_network.dart b/lib/page/_shared/models/mesh_network.dart new file mode 100644 index 000000000..92b98daae --- /dev/null +++ b/lib/page/_shared/models/mesh_network.dart @@ -0,0 +1,132 @@ +import 'package:equatable/equatable.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 { + /// 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]; +} diff --git a/lib/page/_shared/models/mesh_topology_info.dart b/lib/page/_shared/models/mesh_topology_info.dart index f1f3d36d7..216c70097 100644 --- a/lib/page/_shared/models/mesh_topology_info.dart +++ b/lib/page/_shared/models/mesh_topology_info.dart @@ -1,13 +1,16 @@ import 'package:equatable/equatable.dart'; -import 'package:privacy_gui/page/topology/models/node_ui_model.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. +/// +/// NOTE: The [nodes] list contains NodeEntity instances with empty +/// [connectedClients] — client assignment happens in [MeshNetworkBuilder]. class MeshTopologyInfo extends Equatable { /// Mesh nodes discovered via DataElements. - final List nodes; + final List nodes; /// Client MAC (uppercase) → node device ID mapping. final Map clientToNodeMap; @@ -19,10 +22,18 @@ class MeshTopologyInfo extends Equatable { /// 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. @@ -30,11 +41,13 @@ class MeshTopologyInfo extends Equatable { nodes: [], clientToNodeMap: {}, clientSignalMap: {}, + clientBandSsidMap: {}, ); bool get isEmpty => nodes.isEmpty; bool get isNotEmpty => nodes.isNotEmpty; @override - List get props => [nodes, clientToNodeMap, clientSignalMap]; + List get props => + [nodes, clientToNodeMap, clientSignalMap, 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..cc75e4379 --- /dev/null +++ b/lib/page/_shared/models/node_entity.dart @@ -0,0 +1,321 @@ +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 { + // ─── 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, + ]; +} + +/// 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, + ]; +} + +/// 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/wifi_connection_info.dart b/lib/page/_shared/models/wifi_connection_info.dart new file mode 100644 index 000000000..33cbe7694 --- /dev/null +++ b/lib/page/_shared/models/wifi_connection_info.dart @@ -0,0 +1,73 @@ +import 'package:equatable/equatable.dart'; +import 'package:privacy_gui/core/utils/wifi.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 { + /// 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, + ]; +} diff --git a/lib/page/_shared/providers/usp_device_analytics_notifier.dart b/lib/page/_shared/providers/usp_device_analytics_notifier.dart index 20f44aea4..033a75476 100644 --- a/lib/page/_shared/providers/usp_device_analytics_notifier.dart +++ b/lib/page/_shared/providers/usp_device_analytics_notifier.dart @@ -1,7 +1,7 @@ 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'; @@ -33,6 +33,7 @@ class UspDeviceAnalyticsNotifier extends Notifier { ref.listen(devicesDataProvider, (previous, next) { final data = next.valueOrNull; if (data == null) return; + // Use clientDevices to exclude mesh nodes (master/slave) _onDashboardUpdated(data.clientDevices); }); @@ -112,13 +113,18 @@ class UspDeviceAnalyticsNotifier extends Notifier { } /// Returns the set of router MACs (master + slave nodes). + /// Used to clean legacy persisted data that may contain mesh node MACs. Set _getRouterMacs() { - final allDeviceModels = - ref.read(devicesDataProvider).valueOrNull?.deviceModels ?? []; - return allDeviceModels.where((d) => d.isMeshNode).map((d) => d.mac).toSet(); + 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) { + void _onDashboardUpdated(List devices) { // 1. Compute current distribution final distribution = _computeDistribution(devices); @@ -159,13 +165,10 @@ class UspDeviceAnalyticsNotifier extends Notifier { final cutoff = now.subtract(Duration(hours: DeviceAnalyticsState.maxHours)); history = history.where((h) => h.hour.isAfter(cutoff)).toList(); - // Get router MACs to filter out from history (mesh nodes should not appear) - final routerMacs = _getRouterMacs(); - - // Rebuild allKnownMacs from history, excluding router MACs + // Rebuild allKnownMacs from history (clientDevices already excludes mesh nodes) final allMacs = {}; for (final h in history) { - allMacs.addAll(h.activeMacs.where((mac) => !routerMacs.contains(mac))); + allMacs.addAll(h.activeMacs); } state = state.copyWith( @@ -179,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(); @@ -187,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) @@ -204,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( @@ -222,12 +228,38 @@ 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. diff --git a/lib/page/_shared/services/usp_pdf_service.dart b/lib/page/_shared/services/usp_pdf_service.dart index d65a3f4f1..838f6cde2 100644 --- a/lib/page/_shared/services/usp_pdf_service.dart +++ b/lib/page/_shared/services/usp_pdf_service.dart @@ -352,11 +352,9 @@ class UspPdfService { // =========================================================================== static List _buildDevices(PdfReportData data) { - final allDevices = data.deviceModels ?? []; - // Exclude mesh nodes (routers) — only show client devices in report - final devices = allDevices.where((d) => d.isClientDevice).toList(); - 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 / ' @@ -847,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)'), @@ -861,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/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 5088bb12c..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,23 +16,34 @@ 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 and signal strength 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) { @@ -42,6 +54,10 @@ class MeshTopologyBuilder { if (rssi != null) { clientSignalMap[upperMac] = rssi; } + // Store band + SSID for this client + if (band.isNotEmpty || ssid.isNotEmpty) { + clientBandSsidMap[upperMac] = (band: band, ssid: ssid); + } } } } @@ -49,56 +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, clientSignalMap: clientSignalMap, + clientBandSsidMap: clientBandSsidMap, ); } } diff --git a/lib/page/admin/cards/usp_device_info_card.dart b/lib/page/admin/cards/usp_device_info_card.dart index 372c8787f..8f5176ef7 100644 --- a/lib/page/admin/cards/usp_device_info_card.dart +++ b/lib/page/admin/cards/usp_device_info_card.dart @@ -26,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; 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/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/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 a56e56856..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,58 +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 when count is small - final maxCount = - slots.map((s) => s.wifi + s.wired).reduce((a, b) => a > b ? a : b); + // 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, - yAxis: AppChartAxis(min: 0, max: yMax, interval: yInterval), - 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, ), ], ); @@ -385,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 ee0290043..5d41772e0 100644 --- a/lib/page/dashboard/views/components/usp_stats_panel.dart +++ b/lib/page/dashboard/views/components/usp_stats_panel.dart @@ -22,7 +22,7 @@ class UspStatsPanel extends ConsumerWidget { 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/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 c968beea6..b874cf5e0 100644 --- a/lib/page/devices/providers/device_filter_provider.dart +++ b/lib/page/devices/providers/device_filter_provider.dart @@ -1,7 +1,7 @@ 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'; @@ -61,9 +61,9 @@ class DeviceFilterNotifier extends StateNotifier { state = state.copyWith(status: value); } - void setConnections(Set values) { + void setConnections(Set values) { final isEthernetOnly = - values.length == 1 && values.contains(DeviceConnectionType.wired); + values.length == 1 && values.contains(ConnectionType.wired); if (isEthernetOnly) { state = state.copyWith( connections: values, @@ -77,8 +77,8 @@ class DeviceFilterNotifier extends StateNotifier { state = state.copyWith(connections: values); } - void toggleConnection(DeviceConnectionType type) { - var next = Set.from(state.connections); + void toggleConnection(ConnectionType type) { + var next = Set.from(state.connections); if (next.contains(type)) { next.remove(type); } else { @@ -253,15 +253,16 @@ final deviceFilterOptionsProvider = Provider((ref) { ); }); -final filteredDeviceListProvider = Provider>((ref) { +/// Filtered device list — applies every active dimension + search. +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) { - // Status +bool _matches(ClientDevice device, DeviceFilterConfig filter) { + // Status. if (filter.status == DeviceStatusFilter.online && !device.isActive) { return false; } @@ -272,7 +273,7 @@ bool _matches(DeviceUIModel device, DeviceFilterConfig filter) { // Connection type (multi-select OR) if (filter.connections.isNotEmpty) { final deviceType = - device.isWifi ? DeviceConnectionType.wifi : DeviceConnectionType.wired; + device.isWifi ? ConnectionType.wifi : ConnectionType.wired; if (!filter.connections.contains(deviceType)) { return false; } @@ -280,7 +281,7 @@ bool _matches(DeviceUIModel device, DeviceFilterConfig filter) { // BUG FIX: Exclude Ethernet when WiFi-specific filters are active if (filter.hasWifiOnlyFilter && !device.isWifi) { - if (!filter.connections.contains(DeviceConnectionType.wired)) { + if (!filter.connections.contains(ConnectionType.wired)) { return false; } } diff --git a/lib/page/devices/providers/device_filter_state.dart b/lib/page/devices/providers/device_filter_state.dart index 1737bfaae..42d61f829 100644 --- a/lib/page/devices/providers/device_filter_state.dart +++ b/lib/page/devices/providers/device_filter_state.dart @@ -1,7 +1,7 @@ import 'package:equatable/equatable.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/_shared/utils/device_classifier.dart'; -import 'package:privacy_gui/page/topology/models/node_ui_model.dart'; enum DeviceStatusFilter { all, online, offline } @@ -12,7 +12,7 @@ enum PrivateMacFilter { all, privateOnly, publicOnly } class DeviceFilterConfig extends Equatable { final String searchQuery; final DeviceStatusFilter status; - final Set connections; + final Set connections; final Set deviceCategories; final PrivateMacFilter privateMac; final Set signals; @@ -68,13 +68,12 @@ class DeviceFilterConfig extends Equatable { bands.isNotEmpty; bool get isEthernetOnly => - connections.length == 1 && - connections.contains(DeviceConnectionType.wired); + connections.length == 1 && connections.contains(ConnectionType.wired); DeviceFilterConfig copyWith({ String? searchQuery, DeviceStatusFilter? status, - Set? connections, + Set? connections, Set? deviceCategories, PrivateMacFilter? privateMac, Set? signals, @@ -113,7 +112,7 @@ class DeviceFilterConfig extends Equatable { } class DeviceFilterOptions extends Equatable { - final List nodes; + final List nodes; final List ssids; final List bands; final List deviceCategories; diff --git a/lib/page/devices/providers/devices_data_provider.dart b/lib/page/devices/providers/devices_data_provider.dart index e1dea64fb..b94231bee 100644 --- a/lib/page/devices/providers/devices_data_provider.dart +++ b/lib/page/devices/providers/devices_data_provider.dart @@ -5,73 +5,80 @@ 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/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 { 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 List get props => [ codegenContext, - meshTopology.nodes.length, - deviceModels, - nodeModels, - hostNameByMac.length, + meshTopology, + hostNameByMac, + meshNetwork, ]; } @@ -100,7 +107,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 +120,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 +129,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()); @@ -160,8 +164,8 @@ class DevicesDataNotifier extends AsyncNotifier { ); logger.d('[USP][DevicesData]: Fetched — ' - 'deviceModels: ${result.deviceModels.length}, ' - 'nodeModels: ${result.nodeModels.length}'); + '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 +178,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 +191,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, @@ -205,13 +217,11 @@ class DevicesDataNotifier extends AsyncNotifier { logger.d('[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, )); } @@ -257,8 +267,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, @@ -269,17 +279,15 @@ class DevicesDataNotifier extends AsyncNotifier { ); logger.d('[USP][DevicesData]: Refetch (preserve mesh) — ' - 'deviceModels: ${rebuilt.deviceModels.length}, ' - 'nodeModels: ${rebuilt.nodeModels.length}, ' + '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 20fae0562..9be950f9d 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); + MeshTopologyInfo _buildTopologyInfo( + DataElementsNetwork network, + Map bssidToBandMap, + ) { + final result = MeshTopologyBuilder.build( + network, + bssidToBandMap: bssidToBandMap, + ); logger.d('[USP][Dashboard]: Mesh nodes: ${result.nodes.length}, ' - 'client→node mappings: ${result.clientToNodeMap.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,328 +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, then DataElements. - // DataElements provides signal for clients on ALL nodes (including child nodes), - // while WifiClients only covers master node clients. - signalStrength: isWifi - ? (device.signalStrength ?? - wifiClient?.signalStrength ?? - meshTopology.clientSignalMap[mac]) - : 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/usp_device_filter_panel.dart b/lib/page/devices/views/components/usp_device_filter_panel.dart index 8e0646973..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,13 +2,13 @@ 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/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/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; String _signalLabel(BuildContext context, DeviceSignalLevel level) { final nodeLevel = nodeLevelOf(level); @@ -299,17 +299,17 @@ class UspDeviceFilterPanel extends ConsumerWidget { } } -Set _connectionToIndices(Set types) { +Set _connectionToIndices(Set types) { final indices = {}; - if (types.contains(DeviceConnectionType.wifi)) indices.add(0); - if (types.contains(DeviceConnectionType.wired)) indices.add(1); + 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(DeviceConnectionType.wifi); - if (indices.contains(1)) types.add(DeviceConnectionType.wired); +Set _indicesToConnections(Set indices) { + final types = {}; + if (indices.contains(0)) types.add(ConnectionType.wifi); + if (indices.contains(1)) types.add(ConnectionType.wired); return types; } @@ -555,17 +555,17 @@ class UspDeviceFilterChipBar extends ConsumerWidget { label: filter.connections.isEmpty ? loc(context).connection : filter.connections.length == 1 - ? (filter.connections.first == DeviceConnectionType.wifi + ? (filter.connections.first == ConnectionType.wifi ? loc(context).wifi : loc(context).ethernet) : '${loc(context).connection} (${filter.connections.length})', isActive: filter.connections.isNotEmpty, - onTap: () => _showMultiSelectPicker( + onTap: () => _showMultiSelectPicker( context: context, title: loc(context).connection, - items: DeviceConnectionType.values, + items: ConnectionType.values, selected: filter.connections, - labelOf: (v) => v == DeviceConnectionType.wifi + labelOf: (v) => v == ConnectionType.wifi ? loc(context).wifi : loc(context).ethernet, onChanged: notifier.setConnections, 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 bf78a270c..eac76e390 100644 --- a/lib/page/devices/views/components/usp_device_list_tile.dart +++ b/lib/page/devices/views/components/usp_device_list_tile.dart @@ -2,7 +2,7 @@ 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'; @@ -35,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; diff --git a/lib/page/devices/views/usp_device_detail_view.dart b/lib/page/devices/views/usp_device_detail_view.dart index 28e2a5103..3949c2355 100644 --- a/lib/page/devices/views/usp_device_detail_view.dart +++ b/lib/page/devices/views/usp_device_detail_view.dart @@ -6,8 +6,8 @@ import 'package:privacy_gui/localization/localization_hook.dart'; import 'package:privacy_gui/page/_shared/utils/device_classifier.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 +82,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 +99,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 +121,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 +214,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 +263,7 @@ class _UspDeviceDetailViewState extends ConsumerState { // =========================================================================== Widget _buildMultiInterfaceSection( - BuildContext context, DeviceUIModel device) { + BuildContext context, ClientDevice device) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -423,7 +422,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 +466,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 +532,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 +564,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 +596,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 +635,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); @@ -825,7 +793,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/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_setup/models/pnp_state.dart b/lib/page/instant_setup/models/pnp_state.dart index a59d9ae85..5b5f7920d 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. @@ -138,7 +138,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/local_network/providers/ethernet_data_provider.dart b/lib/page/local_network/providers/ethernet_data_provider.dart index bca54c8ef..a8ec0b1d5 100644 --- a/lib/page/local_network/providers/ethernet_data_provider.dart +++ b/lib/page/local_network/providers/ethernet_data_provider.dart @@ -60,9 +60,9 @@ class EthernetDataNotifier extends AsyncNotifier { Future _fetch() async { final svc = ref.read(uspEthernetDataServiceProvider); final devicesData = ref.read(devicesDataProvider).valueOrNull; - final deviceModels = devicesData?.deviceModels ?? []; + final devices = devicesData?.clientDevices ?? []; - final result = await svc.fetch(deviceModels: deviceModels); + final result = await svc.fetch(deviceModels: devices); logger.d('[USP][Ethernet]: Fetch complete — ' '${result.portModels.length} port models'); 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..b838e37e6 100644 --- a/lib/page/local_network/services/usp_ethernet_data_service.dart +++ b/lib/page/local_network/services/usp_ethernet_data_service.dart @@ -5,7 +5,7 @@ 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 +49,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,7 +123,7 @@ 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 = []; @@ -156,7 +156,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(( 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/topology/cards/usp_network_topology_card.dart b/lib/page/topology/cards/usp_network_topology_card.dart index d9aef7d3a..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( diff --git a/lib/page/topology/helpers/usp_topology_builder.dart b/lib/page/topology/helpers/usp_topology_builder.dart index d991f23c5..f7d9b23d0 100644 --- a/lib/page/topology/helpers/usp_topology_builder.dart +++ b/lib/page/topology/helpers/usp_topology_builder.dart @@ -1,11 +1,11 @@ import 'package:privacy_gui/page/_shared/utils/device_classifier.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/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,195 +14,164 @@ 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()}'); - // 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. + // Build extender ID lookup maps 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} ' - '→ hostsMac: $normalizedHostsMac, ' - 'dataElementsId: ${slaveNode.dataElementsId}, ' - 'backhaulParentDeviceId: ${slaveNode.backhaulParentDeviceId}'); } - // 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; - } + // Client devices — use allClients which includes master + slave clients + for (final client in meshNetwork.allClients) { + final clientId = 'client-${client.mac}'; + final isEthernet = !client.isWifi; - final clientId = 'client-${device.mac}'; - final isEthernet = !device.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}, ' - 'normalized=$parentNormalized, ' - 'inExtenders=${extenderNodeIdsNormalized.contains(parentNormalized)}'); + client.parentNodeId!.toUpperCase().replaceAll(':', ''); if (extenderNodeIdsNormalized.contains(parentNormalized)) { final originalDeviceId = normalizedToOriginal[parentNormalized]!; parentId = 'extender-$originalDeviceId'; } - } else { - logger.d('[USP][TopologyBuilder]: Device ${device.displayName} ' - 'hasMesh=$hasMesh, parentNodeId=${device.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 +180,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 +198,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,11 +228,6 @@ 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 @@ -282,16 +252,8 @@ class UspTopologyBuilder { } /// 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 de0344ea4..b6d9a40c3 100644 --- a/lib/page/topology/views/usp_node_detail_view.dart +++ b/lib/page/topology/views/usp_node_detail_view.dart @@ -9,10 +9,10 @@ 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 +71,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 +88,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 +99,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 +119,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 +154,9 @@ class UspNodeDetailView extends ConsumerWidget { AppGap.xs(), DetailStatusBadge( isActive: true, - activeLabel: node.roleLabel, + activeLabel: node.isMaster + ? loc(context).master + : loc(context).slave, ), ], ), @@ -207,7 +209,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; @@ -265,9 +267,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 +302,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 +334,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 +366,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 +402,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 +424,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 +450,7 @@ class UspNodeDetailView extends ConsumerWidget { ), AppGap.xs(), AppText.bodyMedium(DateFormatUtils.formatRelativeTime( - node.lastContactTime)), + backhaul.lastContactTime)), ], ), ), @@ -467,8 +468,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), diff --git a/lib/page/topology/views/usp_topology_view.dart b/lib/page/topology/views/usp_topology_view.dart index 8949c99ae..eb3a2ff31 100644 --- a/lib/page/topology/views/usp_topology_view.dart +++ b/lib/page/topology/views/usp_topology_view.dart @@ -53,11 +53,12 @@ class _UspTopologyViewState extends ConsumerState { ), 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); 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 bc51176c5..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,7 +4,6 @@ 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'; @@ -25,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 ?? '', )); } @@ -84,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; } // ============================================================================= @@ -125,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 @@ -199,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', @@ -213,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: [ @@ -237,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, ), @@ -315,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/services/usp_wifi_data_service.dart b/lib/page/wifi_settings/services/usp_wifi_data_service.dart index 04fe76713..a5310b24e 100644 --- a/lib/page/wifi_settings/services/usp_wifi_data_service.dart +++ b/lib/page/wifi_settings/services/usp_wifi_data_service.dart @@ -441,4 +441,36 @@ class UspWifiDataService { result.sort(); return result; } + + /// 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/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/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/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 647a0be79..0943084ce 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'; // --------------------------------------------------------------------------- @@ -206,88 +207,96 @@ 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: [], + ), + ), ); // --------------------------------------------------------------------------- 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 3ac9b34d8..67e2570da 100644 --- a/test/golden_test/page/devices/fixtures/devices_test_data.dart +++ b/test/golden_test/page/devices/fixtures/devices_test_data.dart @@ -1,81 +1,90 @@ -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', ); @@ -87,7 +96,7 @@ final testReservation = DhcpReservationUIModel( enable: true, ); -List get allDevices => [ +List get allDevices => [ wifiDevice1, wifiDeviceGood, wiredDevice1, 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..a311620d6 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,41 @@ 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: [], -); +const nodeNotFoundState = UspNodeDetailState(); 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/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/providers/usp_device_analytics_notifier_test.dart b/test/page/_shared/providers/usp_device_analytics_notifier_test.dart index 15c2e2be9..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,11 +1,12 @@ 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/system_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/_shared/models/wifi_connection_info.dart'; import 'package:privacy_gui/page/_shared/providers/usp_device_analytics_notifier.dart'; -import 'package:privacy_gui/page/admin/providers/system_info_data_provider.dart'; import 'package:privacy_gui/page/devices/providers/devices_data_provider.dart'; /// Test-only devices data notifier returning canned data. @@ -21,87 +22,78 @@ class _TestDevicesDataNotifier extends DevicesDataNotifier { } } -/// Test-only system info notifier returning canned data. -class _TestSystemInfoDataNotifier extends SystemInfoDataNotifier { - final String serialNumber; - _TestSystemInfoDataNotifier({this.serialNumber = 'TEST_SN_001'}); - - @override - Future build() async { - return SystemInfoData( - model: SystemInfoUIModel( - modelName: 'TestRouter', - hardwareVersion: '1.0', - manufacturer: 'Test', - serialNumber: serialNumber, - softwareVersion: '1.0.0', - uptime: 3600, - totalMemory: 512000, - freeMemory: 256000, - cpuUsage: 25, - ), - ); - } -} - 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({}); }); - ProviderContainer createContainer({ - DevicesData? data, - bool shouldThrow = false, - String serialNumber = 'TEST_SN_001', - }) { + ProviderContainer createContainer( + {DevicesData? data, bool shouldThrow = false}) { final devicesData = data ?? testDevicesData; final container = ProviderContainer( overrides: [ devicesDataProvider.overrideWith(() => _TestDevicesDataNotifier(devicesData, shouldThrow: shouldThrow)), - systemInfoDataProvider.overrideWith( - () => _TestSystemInfoDataNotifier(serialNumber: serialNumber)), ], ); return container; @@ -206,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); @@ -274,73 +266,31 @@ void main() { container.dispose(); }); - test('excludes mesh nodes from distribution', () async { - // Add mesh nodes (master and slave routers) to the device list - const masterNode = DeviceUIModel( - mac: 'AA:BB:CC:DD:EE:05', - ip: '192.168.1.1', - hostName: 'Router', - isActive: true, - isWifi: false, - deviceRole: 'master', - ); - const slaveNode = DeviceUIModel( - mac: 'AA:BB:CC:DD:EE:06', - ip: '192.168.1.2', - hostName: 'Extender', - isActive: true, - isWifi: true, - band: '5GHz', - signalStrength: -50, - deviceRole: 'slave', - ); - // Include mesh nodes alongside regular client devices - final dataWithMesh = DevicesData( - deviceModels: [...testDevices, masterNode, slaveNode], - ); - final container = createContainer(data: dataWithMesh); - await waitForAnalytics(container); - - final state = container.read(uspDeviceAnalyticsProvider); - expect(state.current, isNotNull); - - final dist = state.current!; - // Mesh nodes should NOT be counted — same as without them - // 2 wifi online + 1 wired online = 3 online, 1 offline (no mesh nodes) - expect(dist.onlineCount, 3); - expect(dist.offlineCount, 1); - expect(dist.wifiCount, 2); - expect(dist.wiredCount, 1); - expect(dist.totalCount, 4); - - // Band distribution should NOT include the slave's 5GHz - expect(dist.bandDistribution['5GHz'], 1); // Only wifiDevice5g - expect(dist.bandDistribution['2.4GHz'], 1); - expect(dist.bandDistribution['Wired'], 1); - container.dispose(); - }); - 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); @@ -350,93 +300,79 @@ void main() { container.dispose(); }); - test('filters router MACs from persisted history on load', () async { - // Simulate legacy persisted data that includes router MACs - final now = DateTime.now(); - final currentHour = DateTime(now.year, now.month, now.day, now.hour); - const routerMac = 'AA:BB:CC:DD:EE:05'; // Will be marked as master - const clientMac = 'AA:BB:CC:DD:EE:01'; // Regular client - - final legacyState = DeviceAnalyticsState( - hourlyHistory: [ - HourlyAggregate( - hour: currentHour.subtract(Duration(hours: 1)), - wifiCount: 2, - wiredCount: 0, - activeMacs: {routerMac, clientMac}, // Legacy: contains router MAC - ), - ], - allKnownMacs: {routerMac, clientMac}, - macDisplayNames: {routerMac: 'Router', clientMac: 'Phone'}, + 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, + ), ); - - // Pre-populate SharedPreferences with legacy data - SharedPreferences.setMockInitialValues({ - 'flutter.usp_device_analytics_LEGACY_SN': legacyState.toJsonString(), - }); - - // Create container with a mesh node that has the router MAC - const masterNode = DeviceUIModel( - mac: routerMac, - ip: '192.168.1.1', - hostName: 'Router', + // 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, - isWifi: false, - deviceRole: 'master', + connectionType: ConnectionType.wifi, + parentNodeId: 'CHILD_NODE_ID', + parentNodeName: 'Extender-1', + wifi: const WifiConnectionInfo( + signalStrength: -60, + ), ); - final dataWithRouter = DevicesData( - deviceModels: [wifiDevice5g, masterNode], + // 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', ); - - final container = createContainer( - data: dataWithRouter, - serialNumber: 'LEGACY_SN', + // 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 state = container.read(uspDeviceAnalyticsProvider); - - // Router MAC should be filtered out from allKnownMacs - expect(state.allKnownMacs, isNot(contains(routerMac))); - expect(state.allKnownMacs, contains(clientMac)); - - // Hourly history activeMacs should also exclude router MAC - for (final h in state.hourlyHistory) { - expect(h.activeMacs, isNot(contains(routerMac))); - } - + 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(); }); - - test('persistence is scoped by router serial number', () async { - // First router with SN "ROUTER_A" - final containerA = createContainer(serialNumber: 'ROUTER_A'); - await waitForAnalytics(containerA); - final stateA = containerA.read(uspDeviceAnalyticsProvider); - expect(stateA.hourlyHistory, hasLength(1)); - containerA.dispose(); - - // Second router with different SN "ROUTER_B" - final containerB = createContainer( - data: const DevicesData(deviceModels: []), - serialNumber: 'ROUTER_B', - ); - await waitForAnalytics(containerB); - final stateB = containerB.read(uspDeviceAnalyticsProvider); - // Should NOT inherit history from Router A — different SN means different key - expect(stateB.hourlyHistory, hasLength(1)); // Only its own empty entry - expect(stateB.current!.onlineCount, 0); // Empty device list - containerB.dispose(); - - // Back to Router A — should still have its data - final containerA2 = createContainer(serialNumber: 'ROUTER_A'); - await waitForAnalytics(containerA2); - final stateA2 = containerA2.read(uspDeviceAnalyticsProvider); - // Should have 2 entries now (original + this session's update) - expect(stateA2.hourlyHistory.isNotEmpty, isTrue); - expect( - stateA2.current!.onlineCount, 3); // Has devices from testDevicesData - containerA2.dispose(); - }); }); } 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/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/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/devices/providers/device_detail_provider_test.dart b/test/page/devices/providers/device_detail_provider_test.dart index d686604f7..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,21 +14,21 @@ 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, ); final reservation = DhcpReservationUIModel( @@ -35,8 +38,14 @@ void main() { enable: true, ); - const devicesData = DevicesData( - deviceModels: [wifiDevice, ethernetDevice], + final devicesData = DevicesData( + meshNetwork: MeshNetwork( + master: MasterNode( + deviceId: 'GATEWAY', + model: 'TestRouter', + connectedClients: [wifiDevice, ethernetDevice], + ), + ), ); final dhcpData = DhcpData( @@ -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 f6ddd2d41..c079ded8f 100644 --- a/test/page/devices/providers/device_filter_provider_test.dart +++ b/test/page/devices/providers/device_filter_provider_test.dart @@ -1,86 +1,106 @@ 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, - 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', + connectionType: ConnectionType.wifi, + wifi: const WifiConnectionInfo( + band: '5GHz', + ssidName: 'Home', + // signalStrength intentionally null — firmware state where RSSI is absent. + ), parentNodeId: 'NODE-01', ); - const wifiOnlineFair = DeviceUIModel( + final wifiOnlineFair = ClientDevice( mac: 'AA:AA:AA:AA:AA:06', ip: '192.168.1.106', hostName: 'Laptop', isActive: true, - isWifi: true, - band: '2.4GHz', - ssidName: 'Home', - signalStrength: -75, // fair (-71..-78) + 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, @@ -89,16 +109,49 @@ void main() { 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( @@ -159,7 +212,7 @@ void main() { final container = await createReadyContainer(); container .read(deviceFilterConfigProvider.notifier) - .setConnections({DeviceConnectionType.wifi}); + .setConnections({ConnectionType.wifi}); final filtered = container.read(filteredDeviceListProvider); @@ -172,7 +225,7 @@ void main() { final container = await createReadyContainer(); container .read(deviceFilterConfigProvider.notifier) - .setConnections({DeviceConnectionType.wired}); + .setConnections({ConnectionType.wired}); final filtered = container.read(filteredDeviceListProvider); @@ -183,8 +236,9 @@ void main() { test('selecting both WiFi and Ethernet is same as All', () async { final container = await createReadyContainer(); - container.read(deviceFilterConfigProvider.notifier).setConnections( - {DeviceConnectionType.wifi, DeviceConnectionType.wired}); + container + .read(deviceFilterConfigProvider.notifier) + .setConnections({ConnectionType.wifi, ConnectionType.wired}); final filtered = container.read(filteredDeviceListProvider); @@ -253,6 +307,7 @@ void main() { final filtered = container.read(filteredDeviceListProvider); 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), contains(wifiOfflineHome.mac)); expect(filtered.any((d) => d.mac == wifiOnlineExcellent.mac), isFalse); container.dispose(); @@ -273,25 +328,19 @@ void main() { test( 'regression: Status=All + Node=X must not drop offline devices ' '(they have null parentNodeId in reality)', () async { - const offlineWithNullNode = DeviceUIModel( + // Replace wifiOfflineHome with one that has a null parentNodeId — the + // realistic offline state. Filter by NODE-01 must still include it. + 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) @@ -299,6 +348,7 @@ void main() { final filtered = container.read(filteredDeviceListProvider); + // wifiOnlineExcellent on NODE-01, offline passes through. expect(filtered, hasLength(2)); container.dispose(); }); @@ -395,8 +445,7 @@ void main() { final container = await createReadyContainer(); final notifier = container.read(deviceFilterConfigProvider.notifier); notifier.setSignals({DeviceSignalLevel.excellent}); - notifier.setConnections( - {DeviceConnectionType.wifi, DeviceConnectionType.wired}); + notifier.setConnections({ConnectionType.wifi, ConnectionType.wired}); final filtered = container.read(filteredDeviceListProvider); @@ -478,7 +527,7 @@ void main() { final container = await createReadyContainer(); final notifier = container.read(deviceFilterConfigProvider.notifier); - notifier.setConnections({DeviceConnectionType.wifi}); + notifier.setConnections({ConnectionType.wifi}); notifier.setSignals({DeviceSignalLevel.good}); notifier.setSsidNames({'Home'}); notifier.setBands({'5GHz'}); @@ -505,10 +554,10 @@ void main() { notifier.setSsidNames({'Home'}); notifier.setBands({'5GHz'}); notifier.setNodeIds({'NODE-01'}); - notifier.setConnections({DeviceConnectionType.wired}); + notifier.setConnections({ConnectionType.wired}); final state = container.read(deviceFilterConfigProvider); - expect(state.connections, {DeviceConnectionType.wired}); + expect(state.connections, {ConnectionType.wired}); expect(state.signals, isEmpty); expect(state.ssidNames, isEmpty); expect(state.bands, isEmpty); @@ -522,8 +571,7 @@ void main() { final notifier = container.read(deviceFilterConfigProvider.notifier); notifier.setSsidNames({'Home'}); - notifier.setConnections( - {DeviceConnectionType.wifi, DeviceConnectionType.wired}); + notifier.setConnections({ConnectionType.wifi, ConnectionType.wired}); expect(container.read(deviceFilterConfigProvider).ssidNames, {'Home'}); container.dispose(); @@ -535,7 +583,7 @@ void main() { final notifier = container.read(deviceFilterConfigProvider.notifier); notifier.setSsidNames({'Home'}); - notifier.setConnections({DeviceConnectionType.wired}); + notifier.setConnections({ConnectionType.wired}); notifier.setConnections({}); expect(container.read(deviceFilterConfigProvider).ssidNames, isEmpty); @@ -569,15 +617,10 @@ void main() { .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).ssidNames, {'Home'}); @@ -592,12 +635,9 @@ void main() { 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(); @@ -614,13 +654,7 @@ void main() { 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).includeUnknownSignal, @@ -642,6 +676,7 @@ void main() { expect(options.nodes, hasLength(2)); expect(options.ssids, containsAll(['Guest', 'Home'])); expect(options.bands, containsAll(['2.4GHz', '5GHz'])); + // wifiOnlineNullRssi + wifiOfflineHome both have null RSSI. expect(options.hasUnknownSignalDevices, isTrue); container.dispose(); }); @@ -649,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( @@ -701,7 +734,8 @@ void main() { group('DeviceFilterConfig helpers', () { test('hasWifiOnlyFilter returns true when signal is set', () { - final config = DeviceFilterConfig(signals: {DeviceSignalLevel.excellent}); + final config = + DeviceFilterConfig(signals: const {DeviceSignalLevel.excellent}); expect(config.hasWifiOnlyFilter, isTrue); }); @@ -712,12 +746,12 @@ void main() { }); test('hasWifiOnlyFilter returns true when ssidNames is set', () { - final config = DeviceFilterConfig(ssidNames: {'Home'}); + final config = DeviceFilterConfig(ssidNames: const {'Home'}); expect(config.hasWifiOnlyFilter, isTrue); }); test('hasWifiOnlyFilter returns true when bands is set', () { - final config = DeviceFilterConfig(bands: {'5GHz'}); + final config = DeviceFilterConfig(bands: const {'5GHz'}); expect(config.hasWifiOnlyFilter, isTrue); }); @@ -728,14 +762,14 @@ void main() { test('isEthernetOnly returns true only for single wired selection', () { expect( - DeviceFilterConfig(connections: {DeviceConnectionType.wired}) + DeviceFilterConfig(connections: const {ConnectionType.wired}) .isEthernetOnly, isTrue, ); expect( - DeviceFilterConfig(connections: { - DeviceConnectionType.wifi, - DeviceConnectionType.wired + DeviceFilterConfig(connections: const { + ConnectionType.wifi, + ConnectionType.wired, }).isEthernetOnly, isFalse, ); @@ -748,14 +782,14 @@ void main() { test('activeCount counts non-empty dimensions', () { expect(const DeviceFilterConfig().activeCount, 0); expect( - DeviceFilterConfig(connections: {DeviceConnectionType.wifi}) + DeviceFilterConfig(connections: const {ConnectionType.wifi}) .activeCount, 1, ); expect( DeviceFilterConfig( - connections: {DeviceConnectionType.wifi}, - signals: {DeviceSignalLevel.excellent}, + connections: const {ConnectionType.wifi}, + signals: const {DeviceSignalLevel.excellent}, ).activeCount, 2, ); @@ -763,7 +797,7 @@ void main() { test('activeCount includes deviceCategories and privateMac', () { expect( - DeviceFilterConfig(deviceCategories: {DeviceCategory.phone}) + DeviceFilterConfig(deviceCategories: const {DeviceCategory.phone}) .activeCount, 1, ); @@ -774,7 +808,7 @@ void main() { ); expect( DeviceFilterConfig( - deviceCategories: {DeviceCategory.phone}, + deviceCategories: const {DeviceCategory.phone}, privateMac: PrivateMacFilter.privateOnly, ).activeCount, 2, @@ -784,37 +818,63 @@ void main() { // --------------------------------------------------------------------------- // 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', () { - test('filters by device category', () async { - final container = await createReadyContainer(); + // 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(); - // iPhone and GuestPhone should match phone category - expect(filtered.any((d) => d.hostName == 'iPhone'), isTrue); - expect(filtered.any((d) => d.hostName == 'GuestPhone'), isTrue); - // Desktop should not match - expect(filtered.any((d) => d.hostName == 'Desktop'), isFalse); + // 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(); + 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(); - // iPhone, GuestPhone (phone), iPad (tablet) should match - expect(filtered.any((d) => d.hostName == 'iPhone'), isTrue); - expect(filtered.any((d) => d.hostName == 'iPad'), isTrue); + // phones (OR) tablet match; computer excluded. + expect(macs, {phoneA.mac, phoneB.mac, tablet.mac}); + expect(macs, isNot(contains(computer.mac))); container.dispose(); }); }); @@ -826,30 +886,29 @@ void main() { group('filteredDeviceListProvider Private MAC filter', () { // Private MAC: bit 1 of first byte = 1 (locally administered) // 0x02 = 00000010, bit 1 = 1 -> private - const privateMacDevice = DeviceUIModel( + final privateMacDevice = ClientDevice( mac: '02:00:00:AA:AA:01', // Locally administered (private) ip: '192.168.1.200', hostName: 'PrivatePhone', isActive: true, - isWifi: 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 - const publicMacDevice = DeviceUIModel( + final publicMacDevice = ClientDevice( mac: '00:11:22:33:44:55', // OUI registered (public) ip: '192.168.1.201', hostName: 'PublicPhone', isActive: true, - isWifi: true, + connectionType: ConnectionType.wifi, + wifi: const WifiConnectionInfo(band: '5GHz', ssidName: 'Home'), ); test('privateOnly shows only private MAC devices', () async { final container = await createReadyContainer( - data: const DevicesData( - deviceModels: [privateMacDevice, publicMacDevice], - meshTopology: MeshTopologyInfo(nodes: [], clientToNodeMap: {}), - ), + data: createDevicesData([privateMacDevice, publicMacDevice]), ); container .read(deviceFilterConfigProvider.notifier) @@ -864,10 +923,7 @@ void main() { test('publicOnly shows only public MAC devices', () async { final container = await createReadyContainer( - data: const DevicesData( - deviceModels: [privateMacDevice, publicMacDevice], - meshTopology: MeshTopologyInfo(nodes: [], clientToNodeMap: {}), - ), + data: createDevicesData([privateMacDevice, publicMacDevice]), ); container .read(deviceFilterConfigProvider.notifier) @@ -882,10 +938,7 @@ void main() { test('all shows both private and public MAC devices', () async { final container = await createReadyContainer( - data: const DevicesData( - deviceModels: [privateMacDevice, publicMacDevice], - meshTopology: MeshTopologyInfo(nodes: [], clientToNodeMap: {}), - ), + data: createDevicesData([privateMacDevice, publicMacDevice]), ); // Default is PrivateMacFilter.all, no need to set @@ -908,8 +961,19 @@ 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. void emit(DevicesData next) { _data = next; state = AsyncData(next); diff --git a/test/page/devices/providers/device_filter_state_test.dart b/test/page/devices/providers/device_filter_state_test.dart index e015d64cc..afa490b51 100644 --- a/test/page/devices/providers/device_filter_state_test.dart +++ b/test/page/devices/providers/device_filter_state_test.dart @@ -1,5 +1,5 @@ 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/devices/providers/device_filter_state.dart'; void main() { @@ -24,8 +24,7 @@ void main() { }); test('isActive true when connections is set', () { - const config = - DeviceFilterConfig(connections: {DeviceConnectionType.wifi}); + const config = DeviceFilterConfig(connections: {ConnectionType.wifi}); expect(config.isActive, isTrue); }); @@ -64,7 +63,7 @@ void main() { final updated = config.copyWith( searchQuery: 'test', status: DeviceStatusFilter.offline, - connections: const {DeviceConnectionType.wifi}, + connections: const {ConnectionType.wifi}, signals: const {DeviceSignalLevel.good}, includeUnknownSignal: true, nodeIds: () => const {'node1'}, @@ -74,7 +73,7 @@ void main() { expect(updated.searchQuery, 'test'); expect(updated.status, DeviceStatusFilter.offline); - expect(updated.connections, const {DeviceConnectionType.wifi}); + expect(updated.connections, const {ConnectionType.wifi}); expect(updated.signals, const {DeviceSignalLevel.good}); expect(updated.includeUnknownSignal, isTrue); expect(updated.nodeIds, const {'node1'}); @@ -149,19 +148,18 @@ void main() { test('isEthernetOnly returns true only for single wired selection', () { expect( - const DeviceFilterConfig(connections: {DeviceConnectionType.wired}) + const DeviceFilterConfig(connections: {ConnectionType.wired}) .isEthernetOnly, isTrue, ); expect( - const DeviceFilterConfig(connections: { - DeviceConnectionType.wifi, - DeviceConnectionType.wired - }).isEthernetOnly, + const DeviceFilterConfig( + connections: {ConnectionType.wifi, ConnectionType.wired}) + .isEthernetOnly, isFalse, ); expect( - const DeviceFilterConfig(connections: {DeviceConnectionType.wifi}) + const DeviceFilterConfig(connections: {ConnectionType.wifi}) .isEthernetOnly, isFalse, ); @@ -174,13 +172,13 @@ void main() { test('activeCount counts non-empty dimensions correctly', () { expect(const DeviceFilterConfig().activeCount, 0); expect( - const DeviceFilterConfig(connections: {DeviceConnectionType.wifi}) + const DeviceFilterConfig(connections: {ConnectionType.wifi}) .activeCount, 1, ); expect( const DeviceFilterConfig( - connections: {DeviceConnectionType.wifi}, + connections: {ConnectionType.wifi}, signals: {DeviceSignalLevel.excellent}, ).activeCount, 2, @@ -188,7 +186,7 @@ void main() { expect( const DeviceFilterConfig( status: DeviceStatusFilter.online, - connections: {DeviceConnectionType.wifi}, + connections: {ConnectionType.wifi}, signals: {DeviceSignalLevel.excellent}, nodeIds: {'node1'}, ssidNames: {'Home'}, @@ -207,7 +205,7 @@ void main() { expect( const DeviceFilterConfig( status: DeviceStatusFilter.online, - connections: {DeviceConnectionType.wifi}, + connections: {ConnectionType.wifi}, ).activeCountExcludingStatus, 1, ); 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 227077072..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,8 +3,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/providers/usp_mutation_lock.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/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'; @@ -376,8 +379,8 @@ void main() { test('deviceOptions maps client devices to pure data', () async { when(() => mockService.fetchReservations()).thenAnswer((_) async => []); - final container = createContainerWithDevices(DevicesData( - deviceModels: [ + final container = createContainerWithDevices(_devicesData( + clients: [ _device( mac: 'AA:BB:CC:DD:EE:01', ip: '192.168.1.10', @@ -403,8 +406,8 @@ void main() { test('deviceOptions name falls back to hostName then mac', () async { when(() => mockService.fetchReservations()).thenAnswer((_) async => []); - final container = createContainerWithDevices(DevicesData( - deviceModels: [ + 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'), ], @@ -422,18 +425,26 @@ void main() { test('deviceOptions excludes mesh nodes (master/slave)', () async { when(() => mockService.fetchReservations()).thenAnswer((_) async => []); - final container = createContainerWithDevices(DevicesData( - deviceModels: [ + // 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', - deviceRole: 'client'), - _device( - mac: 'AA:BB:CC:DD:EE:02', - ip: '192.168.1.1', - deviceRole: 'master'), - _device( - mac: 'AA:BB:CC:DD:EE:03', ip: '192.168.1.2', deviceRole: 'slave'), + 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); @@ -442,14 +453,17 @@ void main() { final options = container.read(uspDhcpReservationsProvider.notifier).deviceOptions(); - expect(options, hasLength(1)); - expect(options[0].mac, 'AA:BB:CC:DD:EE:01'); + 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(const DevicesData()); + final container = createContainerWithDevices(_devicesData()); await container.read(devicesDataProvider.future); await Future.delayed(Duration.zero); @@ -472,22 +486,40 @@ class _TestDevicesDataNotifier extends DevicesDataNotifier { Future build() async => _data; } -DeviceUIModel _device({ +ClientDevice _device({ required String mac, required String ip, String hostName = '', String? friendlyName, bool isActive = true, - String? deviceRole, }) { - return DeviceUIModel( + return ClientDevice( mac: mac, ip: ip, hostName: hostName, isActive: isActive, - isWifi: false, + connectionType: ConnectionType.wired, friendlyName: friendlyName, - deviceRole: deviceRole, + ); +} + +/// 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, + ), ); } 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 index fae2e996e..0edaa9b73 100644 --- a/test/page/dhcp/views/dialogs/dhcp_reservation_edit_dialog_test.dart +++ b/test/page/dhcp/views/dialogs/dhcp_reservation_edit_dialog_test.dart @@ -24,7 +24,7 @@ final _testTheme = AppTheme.create( designThemeBuilder: (c) => CustomDesignTheme.fromJson({'style': 'flat'}), ); -const _existing = [ +final _existing = [ DhcpReservationUIModel( mac: 'AA:BB:CC:DD:EE:01', ip: '192.168.1.10', enable: true), DhcpReservationUIModel( @@ -37,8 +37,9 @@ const _existing = [ Future<({String mac, String ip, bool enable})?> _pumpAndOpen( WidgetTester tester, { DhcpReservationUIModel? reservation, - List existing = _existing, + List? existing, }) async { + final existingReservations = existing ?? _existing; ({String mac, String ip, bool enable})? result; bool popped = false; final router = GoRouter( @@ -55,7 +56,7 @@ Future<({String mac, String ip, bool enable})?> _pumpAndOpen( context: context, builder: (_) => DhcpReservationEditDialog( reservation: reservation, - existingReservations: existing, + existingReservations: existingReservations, ), ); popped = true; @@ -188,21 +189,20 @@ void main() { // 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. - const frozen = DhcpReservationUIModel( + 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). - const drifted = DhcpReservationUIModel( + 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: const [drifted]); + 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'); 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 dfbd35384..92cc48efd 100644 --- a/test/page/local_network/providers/dhcp_data_provider_test.dart +++ b/test/page/local_network/providers/dhcp_data_provider_test.dart @@ -9,7 +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/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/devices/providers/devices_data_provider.dart'; import 'package:privacy_gui/page/local_network/providers/dhcp_data_provider.dart'; @@ -91,7 +93,7 @@ void main() { uspClientProvider.overrideWithValue(mockUsp), uspMutationLockProvider.overrideWithValue(UspMutationLock()), devicesDataProvider.overrideWith( - () => _TestDevicesDataNotifier(devicesData ?? const DevicesData()), + () => _TestDevicesDataNotifier(devicesData ?? _emptyDevicesData()), ), ], ); @@ -120,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'}, ), ); @@ -135,26 +137,32 @@ void main() { container.dispose(); }); - test('isOnline enrichment from devicesData deviceModels', () async { + test('isOnline enrichment from devicesData client devices', () async { final container = createContainer( - devicesData: const DevicesData( - hostNameByMac: {'AA:BB:CC:DD:EE:01': 'MyLaptop'}, - deviceModels: [ - DeviceUIModel( - mac: 'AA:BB:CC:DD:EE:01', - ip: '192.168.1.101', - hostName: 'MyLaptop', - isActive: true, - isWifi: true, - ), - DeviceUIModel( - mac: 'AA:BB:CC:DD:EE:02', - ip: '192.168.1.102', - hostName: '', - isActive: false, - isWifi: false, + 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); @@ -168,12 +176,9 @@ void main() { }); test('isOnline is null when no matching device in devicesData', () async { - // Empty deviceModels - no Hosts data available + // No client devices - no Hosts data available final container = createContainer( - devicesData: const DevicesData( - hostNameByMac: {}, - deviceModels: [], - ), + devicesData: _emptyDevicesData(), ); await container.read(devicesDataProvider.future); final data = await container.read(dhcpDataProvider.future); @@ -189,7 +194,7 @@ void main() { overrides: [ uspClientProvider.overrideWithValue(null), devicesDataProvider.overrideWith( - () => _TestDevicesDataNotifier(const DevicesData()), + () => _TestDevicesDataNotifier(_emptyDevicesData()), ), ], ); @@ -236,7 +241,7 @@ void main() { uspClientProvider.overrideWithValue(mockUsp), uspMutationLockProvider.overrideWithValue(UspMutationLock()), devicesDataProvider.overrideWith( - () => _TestDevicesDataNotifier(const DevicesData()), + () => _TestDevicesDataNotifier(_emptyDevicesData()), ), sseInvalidationProvider.overrideWith((ref) => sseController.stream), ], @@ -273,7 +278,7 @@ void main() { uspClientProvider.overrideWithValue(mockUsp), uspMutationLockProvider.overrideWithValue(UspMutationLock()), devicesDataProvider.overrideWith( - () => _TestDevicesDataNotifier(const DevicesData()), + () => _TestDevicesDataNotifier(_emptyDevicesData()), ), sseInvalidationProvider.overrideWith((ref) => sseController.stream), ], @@ -305,7 +310,7 @@ void main() { uspClientProvider.overrideWithValue(mockUsp), uspMutationLockProvider.overrideWithValue(UspMutationLock()), devicesDataProvider.overrideWith( - () => _TestDevicesDataNotifier(const DevicesData()), + () => _TestDevicesDataNotifier(_emptyDevicesData()), ), sseInvalidationProvider.overrideWith((ref) => sseController.stream), ], @@ -338,3 +343,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/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/topology/helpers/usp_topology_builder_test.dart b/test/page/topology/helpers/usp_topology_builder_test.dart index 8503f4446..388cc3c8c 100644 --- a/test/page/topology/helpers/usp_topology_builder_test.dart +++ b/test/page/topology/helpers/usp_topology_builder_test.dart @@ -1,17 +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/core/utils/wifi.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(() { @@ -22,912 +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, + cpuUsage: 25, ); - const meshGateway = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:01', - model: 'MR7500', - isMaster: true, - ); + group('UspTopologyBuilder.buildFromMeshNetwork', () { + // ========================================================================= + // Basic Topology Structure + // ========================================================================= - const meshExtender = NodeUIModel( - deviceId: 'AA:BB:CC:DD:EE:02', - model: 'MX5500', - isMaster: false, - ); + group('basic structure', () { + test('creates gateway node for single-node network', () { + final meshNetwork = DevicesTestData.createSingleNodeNetwork(); - 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', - ); + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); - 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', - ); + expect(topology.nodes, isNotEmpty); + final gateway = + topology.nodes.where((n) => n.type == MeshNodeType.gateway).first; + expect(gateway.id, 'gateway'); + expect(gateway.status, MeshNodeStatus.online); + }); - 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', - ); + test('creates extender nodes for mesh network', () { + final meshNetwork = DevicesTestData.createMeshNetwork(); - // --------------------------------------------------------------------------- - // 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'); - }); + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); - test('all client nodes link to gateway when no mesh', () { - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [wifiDevice, ethernetDevice], - nodeModels: [], - ); + final extenders = + topology.nodes.where((n) => n.type == MeshNodeType.extender); + expect(extenders, hasLength(1)); + expect(extenders.first.id, startsWith('extender-')); + }); - final clientLinks = - topo.links.where((l) => l.sourceId == 'gateway').toList(); - expect(clientLinks, hasLength(2)); - }); + test('creates client nodes for connected devices', () { + final meshNetwork = DevicesTestData.createSingleNodeNetwork(); - test('no extender nodes when mesh is empty', () { - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [wifiDevice], - nodeModels: [], - ); + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); - final extenders = - topo.nodes.where((n) => n.type == MeshNodeType.extender); - expect(extenders, isEmpty); - }); - }); + final clients = + topology.nodes.where((n) => n.type == MeshNodeType.client); + expect(clients, hasLength(2)); // WiFi + Wired from test data + }); - // --------------------------------------------------------------------------- - // 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('creates links between nodes', () { + final meshNetwork = DevicesTestData.createMeshNetwork(); - 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'); - }); + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); - 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); + 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'); + }); }); - 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'); - }); + // ========================================================================= + // Gateway Node Properties + // ========================================================================= - 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'); - }); + group('gateway node', () { + test('uses master displayName when available', () { + final master = DevicesTestData.createMaster( + friendlyName: 'My Router', + ); + final meshNetwork = DevicesTestData.createSingleNodeNetwork( + master: master, + ); - 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'); - }); + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); - 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'); - }); - }); + final gateway = + topology.nodes.where((n) => n.type == MeshNodeType.gateway).first; + expect(gateway.name, 'My Router'); + }); - // --------------------------------------------------------------------------- - // 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('falls back to systemInfo gatewayName', () { + final master = DevicesTestData.createMaster( + friendlyName: null, + hostName: null, + ); + final meshNetwork = DevicesTestData.createSingleNodeNetwork( + master: master.copyWith(connectedClients: []), + ); - 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); - }); + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); - test('online client has online status', () { - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [wifiDevice], - nodeModels: [], - ); + 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); + }); - final client = - topo.nodes.firstWhere((n) => n.type == MeshNodeType.client); - expect(client.status, MeshNodeStatus.online); - }); + test('includes metadata with deviceId and model', () { + final meshNetwork = DevicesTestData.createSingleNodeNetwork(); - test('offline client has offline status', () { - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [offlineDevice], - nodeModels: [], - ); + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); - final client = - topo.nodes.firstWhere((n) => n.type == MeshNodeType.client); - expect(client.status, MeshNodeStatus.offline); - }); + final gateway = + topology.nodes.where((n) => n.type == MeshNodeType.gateway).first; + expect(gateway.metadata?['deviceId'], isNotNull); + expect(gateway.metadata?['isMaster'], isTrue); + }); - test('client name uses displayName (hostName if available)', () { - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [wifiDevice], - nodeModels: [], - ); + test('has level 1.0', () { + final meshNetwork = DevicesTestData.createSingleNodeNetwork(); - final client = - topo.nodes.firstWhere((n) => n.type == MeshNodeType.client); - expect(client.name, 'iPhone'); - }); - }); + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); - // --------------------------------------------------------------------------- - // 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); + final gateway = + topology.nodes.where((n) => n.type == MeshNodeType.gateway).first; + expect(gateway.level, 1.0); + }); }); - test('medium wifi signal maps to medium level', () { - // wifi.dart thresholds: [-65, -71, -78] - // -75 is >= -78 (fair) → level 0.4, LinkQuality.fair - 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.fair); - }); + // ========================================================================= + // Extender Node Properties + // ========================================================================= - 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.good - 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.good); - }); + group('extender nodes', () { + test('uses slave displayName', () { + final slave = DevicesTestData.createWifiSlave( + friendlyName: 'Living Room Extender', + ); + final meshNetwork = MeshNetwork( + master: DevicesTestData.createMaster(), + slaves: [slave], + ); - test('weak wifi signal maps to low level', () { - // wifi.dart thresholds: [-65, -71, -78] - // -80 is < -78 (poor) → LinkQuality.unknown - 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.unknown); - }); + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); - 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); - }); + 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], + ); - 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); + 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'); + }); }); - test('LinkQuality mapping is consistent with getWifiSignalLevel SSoT', () { - // Defense test: ensures topology LinkQuality stays in sync with - // getWifiSignalLevel() from wifi.dart — the single source of truth. - // If this test fails, someone changed the mapping without updating - // both places, causing #1024-like inconsistencies. - - // Test boundary RSSI values for each threshold - const testCases = [ - // (rssi, expectedLevel, expectedLinkQuality) - (-64, NodeSignalLevel.excellent, LinkQuality.excellent), // >= -65 - (-65, NodeSignalLevel.excellent, LinkQuality.excellent), // exactly -65 - (-66, NodeSignalLevel.good, LinkQuality.good), // < -65, >= -71 - (-71, NodeSignalLevel.good, LinkQuality.good), // exactly -71 - (-72, NodeSignalLevel.fair, LinkQuality.fair), // < -71, >= -78 - (-78, NodeSignalLevel.fair, LinkQuality.fair), // exactly -78 - (-79, NodeSignalLevel.poor, LinkQuality.unknown), // < -78 - (-90, NodeSignalLevel.poor, LinkQuality.unknown), // very weak - ]; - - for (final (rssi, expectedSignalLevel, expectedLinkQuality) - in testCases) { - // Verify getWifiSignalLevel returns expected level - final actualSignalLevel = getWifiSignalLevel(rssi); - expect(actualSignalLevel, expectedSignalLevel, - reason: 'getWifiSignalLevel($rssi) should be $expectedSignalLevel'); - - // Verify topology builder produces consistent LinkQuality - final device = DeviceUIModel( - mac: 'AA:AA:AA:AA:AA:AA', - ip: '192.168.1.1', - hostName: 'Test', - isActive: true, - isWifi: true, - signalStrength: rssi, - ); - - final topo = UspTopologyBuilder.build( + // ========================================================================= + // 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, - devices: [device], - nodeModels: [], ); final client = - topo.nodes.firstWhere((n) => n.type == MeshNodeType.client); - expect(client.linkQuality, expectedLinkQuality, - reason: 'RSSI $rssi (${actualSignalLevel.name}) should map to ' - '$expectedLinkQuality, but got ${client.linkQuality}'); - } - }); - }); + 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], + ); - // --------------------------------------------------------------------------- - // 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)); - }); + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); - 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)); - }); - }); + 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], + ); - // --------------------------------------------------------------------------- - // 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); - }); + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); - 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'); - }); + final client = + topology.nodes.where((n) => n.type == MeshNodeType.client).first; + expect(client.status, MeshNodeStatus.offline); + }); - test('gateway metadata deviceId is "gateway" when no mesh nodes', () { - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [], - nodeModels: [], - ); + test('client parentId points to correct node', () { + final slaveClient = DevicesTestData.createSlaveConnectedClient( + parentNodeId: DevicesTestData.slaveMac1, + ); + final meshNetwork = DevicesTestData.createMeshNetwork( + slaveClients: [slaveClient], + ); - final gateway = - topo.nodes.firstWhere((n) => n.type == MeshNodeType.gateway); - expect(gateway.metadata?['deviceId'], 'gateway'); - }); - }); + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); - // --------------------------------------------------------------------------- - // 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); - }); + 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('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'); - }); - }); + test('includes MAC in metadata', () { + final meshNetwork = DevicesTestData.createSingleNodeNetwork(); - // --------------------------------------------------------------------------- - // 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'); - }); + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); - 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'); + final client = + topology.nodes.where((n) => n.type == MeshNodeType.client).first; + expect(client.metadata?['mac'], isNotNull); + }); }); - 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'); - }); + // ========================================================================= + // Link Properties + // ========================================================================= - 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'); - }); - }); + group('links', () { + test('creates link from extender to gateway', () { + final meshNetwork = DevicesTestData.createMeshNetwork(); - // --------------------------------------------------------------------------- - // 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); - }); + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); - 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); - }); + // 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('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); - }); - }); + test('creates links from clients to parent nodes', () { + final meshNetwork = DevicesTestData.createSingleNodeNetwork(); - // --------------------------------------------------------------------------- - // 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); - }); + final topology = UspTopologyBuilder.buildFromMeshNetwork( + meshNetwork: meshNetwork, + info: sysInfo, + ); - 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); - }); + // 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], + ); - 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); - }); + 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('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('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); + }); }); - test('client metadata includes mac address', () { - final topo = UspTopologyBuilder.build( - info: sysInfo, - devices: [wifiDevice], - nodeModels: [], - ); + // ========================================================================= + // 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)); + }); - final client = - topo.nodes.firstWhere((n) => n.type == MeshNodeType.client); - expect(client.metadata?['mac'], '11:22:33:44:55:01'); + 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/services/usp_wifi_data_service_test.dart b/test/page/wifi_settings/services/usp_wifi_data_service_test.dart index 33d19866e..cb1964bef 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 {} @@ -306,4 +308,164 @@ void main() { 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); + }); + }); } From b4779ddaee502f13a428e6e4fd2c1abe1364a300 Mon Sep 17 00:00:00 2001 From: AustinChangLinksys <79675086+AustinChangLinksys@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:24:39 +0800 Subject: [PATCH 45/56] refactor: unify TopBar + DiagnosticLoggable state logging + trace level logs (#1075) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(diagnostics): add DiagnosticLoggable mixin for structured state logging Add a centralized state logging system that captures provider states for diagnostic reports without polluting the main log stream. Changes: - Add DiagnosticLoggable mixin with namedProps for JSON serialization - Add StateLogObserver to capture provider states to cache - Add loggable flag (default true) for opt-out control - Support both async and sync providers - Add trace level filtering for dev-only logs in production - Migrate all Data providers and UIModels to use DiagnosticLoggable State log is captured silently and included only when user downloads the diagnostic report via outputFullWebLog(). Co-Authored-By: Claude Opus 4.5 * fix(diagnostics): address code review findings - Add explicit enum handling in _toJsonSafe (outputs "name" not "EnumType.name") - Add Duration handling (outputs milliseconds) - Simplify Map handling (single branch handles all Map types) - Add @visibleForTesting getters for state log cache verification - Add tests for enum, Duration, non-string map keys - Fix observer tests to verify actual cache contents - Document that didUpdateProvider only fires on state changes (not initial build) Co-Authored-By: Claude Opus 4.5 * fix(test): update DhcpData test for DiagnosticLoggable props change The test was asserting `props == [2, 1]` (lengths), but after migrating to DiagnosticLoggable, props now contains the full namedProps values. Updated the test to verify equality and list lengths separately. Co-Authored-By: Claude Opus 4.5 * refactor(ui): unify TopBar to UspTopBar - Delete legacy `top_bar.dart` — it was a JNAP-era component - Update `ui_kit_page_view.dart` to use UspTopBar - Enhance UspTopBar: - Add optional `controllerProvider` param (defaults to uspMenuController) - Add DebugObserver mixin for rapid-tap log download - Fix Apps button visibility based on login state Co-Authored-By: Claude Opus 4.5 * refactor(logging): use trace level for dev-only logs - Change verbose development logs from `logger.d()` to `logger.t()` - App build lifecycle (`[App]: build`) - Throttler dispatch details - WiFi/Topology internal diagnostics - Devices/mesh internal matching logs - Simplify SSE log tags (`[USP][SSE][Bootstrap]` → `[SSE]`) - Remove redundant debug logs in devices service (mesh node matching) - Production builds filter out trace level, reducing log noise Co-Authored-By: Claude Opus 4.5 * fix: address code review warnings and suggestions for PR #1075 Warnings fixed: - W-1: Mask sensitive data (MAC/serial/password) in state log cache - W-2: Change SSE pretty-print log from debug to trace level - W-3/W-4: Add explicit props override to WifiData for reliable equality - W-5: Replace IconButton with AppIconButton + Tooltip Suggestions fixed: - S-1: Log warning on WiFi fallback fetch failure instead of silent catch - S-2: Remove dead code (tag == 'State' branch in logger) - S-3: Add Map equality tests for DiagnosticLoggable - S-4: Add diagnosticName getter to avoid dart2js minification issues - S-5: Add .select() to theme config providers in UspTopBar Additional improvements: - USP request ID format changed from incremental integer to LNU{HEX-TIMESTAMP}{3-CHAR-RANDOM} (e.g., LNU6A4B4BD8F90) Co-Authored-By: Claude Opus 4.5 * fix: address round 2 review feedback - W-1: Remove no-op identity selector on demoThemeConfigProvider - W-2: Use millisecond timestamp + 16-bit random for request ID (reduces collision probability from >50% at 91 req/s to negligible) - Update stale doc comment in logger.dart Co-Authored-By: Claude Opus 4.5 * fix(dhcp): repair reservation dialog test broken by dev-2.6.0 merge The DhcpReservationEditDialog test (added in #1078) used `const DhcpReservationUIModel(...)`, but #1087 later made that model's constructor non-const (MAC uppercase normalization). The two landed on dev-2.6.0 without a full re-check, so the test file had 9 compile errors that only surfaced after merging dev-2.6.0 into this branch. - const -> final for DhcpReservationUIModel literals; nullable `existing` param with in-body fallback (const default no longer valid). - _enterMac/_enterIp now blur the field after typing. The dialog validates on FocusNode blur, not per keystroke, so single-field entry tests (duplicate IP, edit-to-existing MAC) never ran _validate() and saw null errorText. Blur mirrors a real user tabbing away. Co-Authored-By: Claude Opus 4.8 * fix(firewall): add explicit FirewallData props override for reliable equality Addresses PR #1075 review: FirewallData derived props from namedProps, which narrowed equality to ruleSummaries.length and dropped ruleContext and dmzSummaries. A rule whose content changed without changing the count (or a DMZ-only change) would compare equal, so the provider skipped notifying listeners and the UI went stale. Mirror the WifiData fix: keep namedProps lean for diagnostic JSON output, but override props with the full field list [firewallModel, ruleContext, ruleSummaries, dmzModel, dmzSummaries]. Add regression tests asserting content-only rule changes and DMZ-only changes break equality, and that namedProps stays lean. Co-Authored-By: Claude Opus 4.8 * feat(diagnostics): extend state logging to MeshNetwork entities The #1068 mesh refactor introduced ClientDevice, NodeEntity (MasterNode/ SlaveNode), MeshNetwork, BackhaulInfo, ClientInterfaceInfo and WifiConnectionInfo, all built `with EquatableMixin`. They nest inside the DiagnosticLoggable states DevicesData and MeshTopologyInfo, but since they were neither Equatable nor DiagnosticLoggable, _toJsonSafe fell through to `toString()` and serialized them as opaque "Instance of 'ClientDevice'" in diagnostic reports. DiagnosticLoggable is constrained `on Equatable`, so these EquatableMixin models cannot mix it in. Extract the JSON contract into a new DiagnosticNamed mixin (diagnosticName + namedProps + JSON toString) with no Equatable constraint, and have the six new models mix it in alongside EquatableMixin — keeping their own `props` for equality untouched. _toJsonSafe now recognizes DiagnosticNamed, so nested entities render as keyed JSON, consistent with the existing UIModel logging. DiagnosticLoggable keeps its `on Equatable` constraint (implements DiagnosticNamed for the nested-serialization type check) so the 30+ existing `extends Equatable with DiagnosticLoggable` classes need no change. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.5 --- lib/app.dart | 4 +- lib/components/layouts/root_container.dart | 4 +- lib/components/styled/top_bar.dart | 74 --- lib/components/ui_kit_page_view.dart | 8 +- lib/core/usp/providers/sse_providers.dart | 7 +- .../services/bridge_request_throttler.dart | 2 +- .../usp/services/sse_connection_manager.dart | 28 +- lib/core/usp/services/sse_event_router.dart | 16 +- lib/core/usp/services/sse_local_strategy.dart | 20 +- .../usp/services/sse_remote_strategy.dart | 17 +- .../services/sse_subscription_registry.dart | 12 +- lib/core/usp/services/usp_client.dart | 49 +- lib/core/utils/logger.dart | 117 ++++- lib/core/utils/state_log_observer.dart | 49 ++ lib/framework/diagnostic_loggable.dart | 185 +++++++ lib/main.dart | 2 + lib/page/_shared/models/backhaul_info.dart | 21 +- .../models/client_connection_detail.dart | 14 +- lib/page/_shared/models/client_device.dart | 41 +- .../_shared/models/dhcp_client_ui_model.dart | 16 +- .../models/dhcp_reservation_ui_model.dart | 13 +- .../models/ethernet_port_ui_model.dart | 35 +- .../_shared/models/lan_info_ui_model.dart | 28 +- lib/page/_shared/models/mesh_network.dart | 13 +- .../_shared/models/mesh_topology_info.dart | 14 +- lib/page/_shared/models/node_entity.dart | 45 +- .../models/port_forwarding_rule_ui_model.dart | 26 +- .../_shared/models/system_info_ui_model.dart | 53 +- .../models/time_settings_ui_model.dart | 22 +- .../_shared/models/wan_status_ui_model.dart | 26 +- .../_shared/models/wifi_client_ui_model.dart | 22 +- .../_shared/models/wifi_connection_info.dart | 15 +- .../_shared/models/wifi_radio_ui_model.dart | 55 +- .../providers/system_info_data_provider.dart | 13 +- .../admin/providers/time_data_provider.dart | 15 +- .../providers/devices_data_provider.dart | 24 +- .../services/usp_devices_data_service.dart | 2 +- lib/page/dmz/models/dmz_ui_model.dart | 13 +- .../firewall/models/firewall_ui_model.dart | 26 +- .../providers/firewall_data_provider.dart | 23 +- .../models/firmware_image_ui_model.dart | 17 +- .../firmware_banks_data_provider.dart | 8 +- .../providers/wan_data_provider.dart | 12 +- .../providers/dhcp_data_provider.dart | 16 +- .../providers/ethernet_data_provider.dart | 15 +- .../providers/lan_data_provider.dart | 11 +- .../models/port_triggering_rule_ui_model.dart | 47 +- .../port_forwarding_data_provider.dart | 8 +- .../port_triggering_data_provider.dart | 8 +- lib/page/shell/usp_top_bar.dart | 115 +++-- .../helpers/usp_topology_builder.dart | 22 +- .../providers/wifi_data_provider.dart | 26 +- .../services/usp_wifi_data_service.dart | 17 +- test/core/utils/state_log_observer_test.dart | 357 +++++++++++++ test/framework/diagnostic_loggable_test.dart | 478 ++++++++++++++++++ .../dhcp_reservation_edit_dialog_test.dart | 8 + .../providers/usp_firewall_notifier_test.dart | 49 ++ .../providers/dhcp_data_provider_test.dart | 5 +- 58 files changed, 1928 insertions(+), 460 deletions(-) delete mode 100644 lib/components/styled/top_bar.dart create mode 100644 lib/core/utils/state_log_observer.dart create mode 100644 lib/framework/diagnostic_loggable.dart create mode 100644 test/core/utils/state_log_observer_test.dart create mode 100644 test/framework/diagnostic_loggable_test.dart diff --git a/lib/app.dart b/lib/app.dart index ed71e533d..78e0a5412 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -96,7 +96,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(); @@ -252,7 +252,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..b94346dea 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( @@ -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/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/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/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 188a883e4..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; @@ -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/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/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/main.dart b/lib/main.dart index 057cb47c0..d591dbbc6 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -14,6 +14,7 @@ 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'; @@ -134,6 +135,7 @@ Widget app() { return ProviderScope( observers: [ ProviderLogger(), + StateLogObserver(), ], child: const LinksysApp(), ); diff --git a/lib/page/_shared/models/backhaul_info.dart b/lib/page/_shared/models/backhaul_info.dart index 6eac048f6..635c89598 100644 --- a/lib/page/_shared/models/backhaul_info.dart +++ b/lib/page/_shared/models/backhaul_info.dart @@ -1,9 +1,10 @@ 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 { +class BackhaulInfo with EquatableMixin, DiagnosticNamed { /// Media type description, e.g., "IEEE 802.11ax", "Ethernet". final String mediaType; @@ -74,4 +75,22 @@ class BackhaulInfo with EquatableMixin { 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 index 011d528af..f7c46da13 100644 --- a/lib/page/_shared/models/client_device.dart +++ b/lib/page/_shared/models/client_device.dart @@ -1,5 +1,6 @@ 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'; @@ -19,7 +20,7 @@ extension ConnectionTypeExt on ConnectionType { /// 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 { +class ClientInterfaceInfo with EquatableMixin, DiagnosticNamed { /// MAC address of this interface. final String mac; @@ -68,13 +69,26 @@ class ClientInterfaceInfo with EquatableMixin { 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 { +final class ClientDevice extends NetworkEntity with DiagnosticNamed { // ─── Identity ─── /// MAC address (uppercase, normalized). final String mac; @@ -284,6 +298,29 @@ final class ClientDevice extends NetworkEntity { 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. diff --git a/lib/page/_shared/models/dhcp_client_ui_model.dart b/lib/page/_shared/models/dhcp_client_ui_model.dart index 6a83ab83a..f52ad66cc 100644 --- a/lib/page/_shared/models/dhcp_client_ui_model.dart +++ b/lib/page/_shared/models/dhcp_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 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 { +class DhcpClientUIModel extends Equatable with DiagnosticLoggable { /// MAC address (normalized to uppercase). final String mac; final String ip; @@ -69,6 +70,15 @@ class DhcpClientUIModel extends Equatable { String get displayName => hostName.isNotEmpty ? hostName : mac; @override - List get props => - [mac, ip, leaseActive, isOnline, 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 a885236ac..01f5a843b 100644 --- a/lib/page/_shared/models/dhcp_reservation_ui_model.dart +++ b/lib/page/_shared/models/dhcp_reservation_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 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). @@ -35,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 index 92b98daae..961aee38c 100644 --- a/lib/page/_shared/models/mesh_network.dart +++ b/lib/page/_shared/models/mesh_network.dart @@ -1,4 +1,5 @@ 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'; @@ -7,7 +8,7 @@ import 'package:privacy_gui/page/_shared/models/node_entity.dart'; /// 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 { +class MeshNetwork with EquatableMixin, DiagnosticNamed { /// The master (gateway) node. final MasterNode master; @@ -129,4 +130,14 @@ class MeshNetwork with EquatableMixin { @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 216c70097..59f3c30ed 100644 --- a/lib/page/_shared/models/mesh_topology_info.dart +++ b/lib/page/_shared/models/mesh_topology_info.dart @@ -1,4 +1,5 @@ import 'package:equatable/equatable.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. @@ -8,7 +9,7 @@ import 'package:privacy_gui/page/_shared/models/node_entity.dart'; /// /// NOTE: The [nodes] list contains NodeEntity instances with empty /// [connectedClients] — client assignment happens in [MeshNetworkBuilder]. -class MeshTopologyInfo extends Equatable { +class MeshTopologyInfo extends Equatable with DiagnosticLoggable { /// Mesh nodes discovered via DataElements. final List nodes; @@ -48,6 +49,13 @@ class MeshTopologyInfo extends Equatable { bool get isNotEmpty => nodes.isNotEmpty; @override - List get props => - [nodes, clientToNodeMap, clientSignalMap, clientBandSsidMap]; + String get diagnosticName => 'MeshTopologyInfo'; + + @override + Map get namedProps => { + 'nodes': nodes, + 'clientToNodeMap': clientToNodeMap, + 'clientSignalMap': clientSignalMap, + 'clientBandSsidMap': clientBandSsidMap, + }; } diff --git a/lib/page/_shared/models/node_entity.dart b/lib/page/_shared/models/node_entity.dart index cc75e4379..185bf2533 100644 --- a/lib/page/_shared/models/node_entity.dart +++ b/lib/page/_shared/models/node_entity.dart @@ -1,3 +1,4 @@ +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'; @@ -11,7 +12,7 @@ import 'package:privacy_gui/page/_shared/models/network_entity.dart'; /// case SlaveNode s: print('Extender via ${s.backhaul.linkType}'); /// } /// ``` -sealed class NodeEntity extends NetworkEntity { +sealed class NodeEntity extends NetworkEntity with DiagnosticNamed { // ─── Identity ─── /// Device ID (MAC address, uppercase, normalized). String get deviceId; @@ -194,6 +195,28 @@ final class MasterNode extends NodeEntity { 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. @@ -301,6 +324,26 @@ final class SlaveNode extends NodeEntity { 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. 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/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 index 33cbe7694..3c9b49fbe 100644 --- a/lib/page/_shared/models/wifi_connection_info.dart +++ b/lib/page/_shared/models/wifi_connection_info.dart @@ -1,11 +1,12 @@ 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 { +class WifiConnectionInfo with EquatableMixin, DiagnosticNamed { /// Signal strength in dBm (RSSI). Typically -30 to -90. final int? signalStrength; @@ -70,4 +71,16 @@ class WifiConnectionInfo with EquatableMixin { 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 94089d1f1..8b96c8981 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; @@ -57,23 +58,26 @@ class WifiRadioUIModel extends Equatable { } @override - List get props => [ - instancePath, - band, - enable, - transmitPower, - maxBitRate, - channel, - autoChannelEnable, - channelBandwidth, - supportedStandards, - possibleChannels, - 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, + '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; @@ -99,13 +103,16 @@ class WifiAccessPointUIModel extends Equatable { }); @override - List get props => [ - enable, - ssidName, - securityMode, - encryptionMode, - isGuest, - accessPointInstancePath, - ssidInstancePath, - ]; + 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/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/devices/providers/devices_data_provider.dart b/lib/page/devices/providers/devices_data_provider.dart index b94231bee..f2a85d332 100644 --- a/lib/page/devices/providers/devices_data_provider.dart +++ b/lib/page/devices/providers/devices_data_provider.dart @@ -4,6 +4,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/admin/providers/system_info_data_provider.dart'; import 'package:privacy_gui/page/_shared/models/client_device.dart'; import 'package:privacy_gui/page/_shared/models/mesh_network.dart'; @@ -21,7 +22,7 @@ export 'package:privacy_gui/page/devices/services/usp_devices_data_service.dart' // Data Model (Layer 1 — MeshNetwork as SSoT) // --------------------------------------------------------------------------- -class DevicesData extends Equatable { +class DevicesData extends Equatable with DiagnosticLoggable { final DevicesCodegenContext codegenContext; final MeshTopologyInfo meshTopology; @@ -73,6 +74,18 @@ class DevicesData extends Equatable { ); } + @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, @@ -163,7 +176,7 @@ class DevicesDataNotifier extends AsyncNotifier { systemInfo: sysData?.model, ); - logger.d('[USP][DevicesData]: Fetched — ' + logger.t('[USP][DevicesData]: Fetched — ' 'clients: ${result.meshNetwork.totalClientCount}, ' 'nodes: ${result.meshNetwork.allNodes.length}'); @@ -215,7 +228,7 @@ class DevicesDataNotifier extends AsyncNotifier { systemInfo: sysData?.model, ); - logger.d('[USP][DevicesData]: Mesh update — ' + logger.t('[USP][DevicesData]: Mesh update — ' 'meshNodes: ${meshTopology.nodes.length}, ' 'clients: ${meshNetwork.totalClientCount}'); @@ -237,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); @@ -278,7 +288,7 @@ class DevicesDataNotifier extends AsyncNotifier { systemInfo: sysData?.model, ); - logger.d('[USP][DevicesData]: Refetch (preserve mesh) — ' + logger.t('[USP][DevicesData]: Refetch (preserve mesh) — ' 'clients: ${meshNetwork.totalClientCount}, ' 'existingMesh: ${existingMesh.nodes.length}'); diff --git a/lib/page/devices/services/usp_devices_data_service.dart b/lib/page/devices/services/usp_devices_data_service.dart index 9be950f9d..9932bc1b5 100644 --- a/lib/page/devices/services/usp_devices_data_service.dart +++ b/lib/page/devices/services/usp_devices_data_service.dart @@ -159,7 +159,7 @@ class UspDevicesDataService { network, bssidToBandMap: bssidToBandMap, ); - logger.d('[USP][Dashboard]: Mesh nodes: ${result.nodes.length}, ' + logger.t('[USP][Dashboard]: Mesh nodes: ${result.nodes.length}, ' 'client→node mappings: ${result.clientToNodeMap.length}, ' 'band/SSID mappings: ${result.clientBandSsidMap.length}'); return result; 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/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/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/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/local_network/providers/dhcp_data_provider.dart b/lib/page/local_network/providers/dhcp_data_provider.dart index 61ee397ae..2a14f1aa4 100644 --- a/lib/page/local_network/providers/dhcp_data_provider.dart +++ b/lib/page/local_network/providers/dhcp_data_provider.dart @@ -3,8 +3,8 @@ 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'; @@ -12,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; @@ -22,7 +22,13 @@ class DhcpData extends Equatable { }); @override - List get props => [clientModels, reservationModels]; + String get diagnosticName => 'DhcpData'; + + @override + Map get namedProps => { + 'clientModels': clientModels, + 'reservationModels': reservationModels, + }; } // ── Provider ── @@ -84,10 +90,6 @@ class DhcpDataNotifier extends AsyncNotifier { isOnlineByMac: isOnlineByMac, ); - logger.d('[USP][DhcpData]: Fetched — ' - 'clients: ${result.clientModels.length}, ' - 'reservations: ${result.reservationModels.length}'); - return DhcpData( clientModels: result.clientModels, reservationModels: result.reservationModels, diff --git a/lib/page/local_network/providers/ethernet_data_provider.dart b/lib/page/local_network/providers/ethernet_data_provider.dart index a8ec0b1d5..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(); } }); @@ -64,9 +68,6 @@ class EthernetDataNotifier extends AsyncNotifier { final result = await svc.fetch(deviceModels: devices); - logger.d('[USP][Ethernet]: Fetch complete — ' - '${result.portModels.length} port models'); - 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/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/shell/usp_top_bar.dart b/lib/page/shell/usp_top_bar.dart index f1cdbff10..45895b3d5 100644 --- a/lib/page/shell/usp_top_bar.dart +++ b/lib/page/shell/usp_top_bar.dart @@ -12,70 +12,95 @@ 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?.loginType != null && + v.value?.loginType != LoginType.none)) && + (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/topology/helpers/usp_topology_builder.dart b/lib/page/topology/helpers/usp_topology_builder.dart index f7d9b23d0..82bbed899 100644 --- a/lib/page/topology/helpers/usp_topology_builder.dart +++ b/lib/page/topology/helpers/usp_topology_builder.dart @@ -1,6 +1,7 @@ import 'package:privacy_gui/page/_shared/utils/device_classifier.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/logger.dart'; import 'package:privacy_gui/core/utils/wifi.dart'; import 'package:privacy_gui/page/_shared/models/client_device.dart' hide ConnectionType; @@ -59,7 +60,14 @@ class UspTopologyBuilder { }, )); - // Build extender ID lookup maps + // 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 = {}; final normalizedToOriginal = {}; final deviceIdToExtenderId = {}; @@ -81,6 +89,10 @@ class UspTopologyBuilder { deviceIdToExtenderId[normalizedDeMac] = extenderId; } } + logger.t('[USP][TopologyBuilder]: Slave ${slave.deviceId} ' + '→ hostsMac: $normalizedHostsMac, ' + 'dataElementsId: ${slave.dataElementsId}, ' + 'backhaulParentDeviceId: ${slave.backhaul.parentNodeId}'); } // Slave nodes @@ -145,10 +157,18 @@ class UspTopologyBuilder { if (meshNetwork.hasMesh && client.parentNodeId != null) { final parentNormalized = client.parentNodeId!.toUpperCase().replaceAll(':', ''); + logger.t('[USP][TopologyBuilder]: Device ${client.displayName} ' + 'parentNodeId=${client.parentNodeId}, ' + 'normalized=$parentNormalized, ' + 'inExtenders=${extenderNodeIdsNormalized.contains(parentNormalized)}'); if (extenderNodeIdsNormalized.contains(parentNormalized)) { final originalDeviceId = normalizedToOriginal[parentNormalized]!; parentId = 'extender-$originalDeviceId'; } + } else { + logger.t('[USP][TopologyBuilder]: Device ${client.displayName} ' + 'hasMesh=${meshNetwork.hasMesh}, ' + 'parentNodeId=${client.parentNodeId} → gateway'); } final category = DeviceClassifier.classify( 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_data_service.dart b/lib/page/wifi_settings/services/usp_wifi_data_service.dart index a5310b24e..67247a597 100644 --- a/lib/page/wifi_settings/services/usp_wifi_data_service.dart +++ b/lib/page/wifi_settings/services/usp_wifi_data_service.dart @@ -167,7 +167,7 @@ class UspWifiDataService { for (final ssid in ssids.items) if (isGuestSsid(ssid)) _ensureTrailingDot(ssid.instancePath), }; - logger.d('[USP][WiFi] Total guest SSID paths: ${guestSsidPaths.length}'); + 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. @@ -232,7 +232,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 { @@ -241,17 +240,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 {}; } } @@ -345,9 +337,6 @@ class UspWifiDataService { _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; 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/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/page/dhcp/views/dialogs/dhcp_reservation_edit_dialog_test.dart b/test/page/dhcp/views/dialogs/dhcp_reservation_edit_dialog_test.dart index 0edaa9b73..ea2075ec5 100644 --- a/test/page/dhcp/views/dialogs/dhcp_reservation_edit_dialog_test.dart +++ b/test/page/dhcp/views/dialogs/dhcp_reservation_edit_dialog_test.dart @@ -90,11 +90,19 @@ AppTextField _ipField() => 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() { 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/local_network/providers/dhcp_data_provider_test.dart b/test/page/local_network/providers/dhcp_data_provider_test.dart index 92cc48efd..654b726a0 100644 --- a/test/page/local_network/providers/dhcp_data_provider_test.dart +++ b/test/page/local_network/providers/dhcp_data_provider_test.dart @@ -222,12 +222,15 @@ void main() { container.dispose(); }); - test('DhcpData props uses full lists 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.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(); }); From 69eb8f1caada8933edbd70532c08c3a416b30daa Mon Sep 17 00:00:00 2001 From: AustinChangLinksys <79675086+AustinChangLinksys@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:06:32 +0800 Subject: [PATCH 46/56] fix(l10n): align port forwarding naming with 1.x (#1097) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(l10n): align port forwarding naming with 1.x (#1081) - Rename "Port Triggering" to "Port Range Triggering" across all locales - Update tab labels to full names without count (moved count to section titles) - Update related strings: add/edit dialogs, empty state messages Co-Authored-By: Claude Opus 4.5 * fix(l10n): reuse existing section keys for port forwarding tab labels (#1097 review) Drop the three new *Tab keys (singlePortForwardingTab, portRangeForwardingTab, portRangeTriggeringTab) and reuse the existing section keys (singlePortForwarding, portRangeForwarding, portTriggering) for the tab labels. The new keys duplicated existing keys with identical English values but were translated independently, causing the same concept to render two different ways on one screen (e.g. zh_TW tab "轉發" vs. section title "轉寄"). Reusing the section keys removes the duplication and the translation divergence in one step. English UI is unchanged (identical values); other locales now use one consistent translation per concept. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.5 --- lib/l10n/app_ar.arb | 11 +++---- lib/l10n/app_da.arb | 11 +++---- lib/l10n/app_de.arb | 11 +++---- lib/l10n/app_el.arb | 11 +++---- lib/l10n/app_en.arb | 32 +++---------------- lib/l10n/app_es.arb | 11 +++---- lib/l10n/app_es_ar.arb | 11 +++---- lib/l10n/app_fi.arb | 11 +++---- lib/l10n/app_fr.arb | 11 +++---- lib/l10n/app_fr_ca.arb | 11 +++---- lib/l10n/app_id.arb | 11 +++---- lib/l10n/app_it.arb | 11 +++---- lib/l10n/app_ja.arb | 11 +++---- lib/l10n/app_ko.arb | 11 +++---- lib/l10n/app_nb.arb | 11 +++---- lib/l10n/app_nl.arb | 11 +++---- lib/l10n/app_pl.arb | 11 +++---- lib/l10n/app_pt.arb | 11 +++---- lib/l10n/app_pt_pt.arb | 11 +++---- lib/l10n/app_ru.arb | 11 +++---- lib/l10n/app_sv.arb | 11 +++---- lib/l10n/app_th.arb | 11 +++---- lib/l10n/app_tr.arb | 11 +++---- lib/l10n/app_vi.arb | 11 +++---- lib/l10n/app_zh.arb | 11 +++---- lib/l10n/app_zh_TW.arb | 11 +++---- .../views/components/usp_port_range_tab.dart | 3 +- .../components/usp_port_triggering_tab.dart | 3 +- .../views/components/usp_single_port_tab.dart | 3 +- .../usp_port_forwarding_detail_view.dart | 6 ++-- .../golden_framework/golden_runner.dart | 23 +++++++++++-- 31 files changed, 134 insertions(+), 211 deletions(-) diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index a10093827..34d1d4943 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -385,7 +385,7 @@ "addDeviceManually": "إضافة جهاز يدويًا", "addPortForwarding": "إضافة إعادة توجيه منفذ", "addPortRangeForwarding": "إضافة إعادة توجيه نطاق منافذ", - "addPortTriggering": "إضافة تشغيل منفذ", + "addPortTriggering": "إضافة تشغيل نطاق المنافذ", "addedWidgetNamed": "تمت إضافة {name}", "adding": "جارٍ الإضافة…", "additionalFiltersOnlineOnly": "تتوفر عوامل التصفية الإضافية للأجهزة المتصلة فقط.", @@ -593,7 +593,7 @@ "dynamicFrequencySelection": "الاختيار الديناميكي للتردد (DFS)", "editPortForwarding": "تحرير إعادة توجيه المنفذ", "editPortRangeForwarding": "تحرير إعادة توجيه نطاق المنافذ", - "editPortTriggering": "تحرير تشغيل المنفذ", + "editPortTriggering": "تحرير تشغيل نطاق المنافذ", "editRule": "تحرير القاعدة", "editStaticRoute": "تحرير مسار ثابت", "editTimeSettings": "تحرير إعدادات الوقت", @@ -802,7 +802,7 @@ "noLogFilesAvailable": "لا توجد ملفات سجل متاحة على جهاز التوجيه هذا", "noPortMappingsConfigured": "لم يتم تكوين تعيينات منافذ", "noPortRangeRules": "لم يتم تكوين قواعد إعادة توجيه نطاق منافذ", - "noPortTriggeringRules": "لم يتم تكوين قواعد تشغيل منافذ", + "noPortTriggeringRules": "لم يتم تكوين قواعد تشغيل نطاق المنافذ", "noPresetSelected": "لم يتم اختيار إعداد مسبق", "noReservationIpMayChange": "لا يوجد حجز. قد يتغير IP عند إعادة الاتصال.", "noSinglePortRules": "لم يتم تكوين قواعد إعادة توجيه منفذ مفرد", @@ -858,9 +858,8 @@ "portMapping": "تعيين المنافذ", "portMappingSubtitle": "قواعد إعادة توجيه المنافذ وتكوين DMZ", "portMustBe1To65535": "يجب أن يكون المنفذ من 1 إلى 65535", - "portRangeWithCount": "نطاق المنافذ ({count})", "portRules": "قواعد المنافذ", - "portTriggering": "تشغيل المنافذ", + "portTriggering": "تشغيل نطاق المنافذ", "ports": "المنافذ", "potentialIssues": "مشكلات محتملة", "pppStatus": "حالة PPP", @@ -972,7 +971,6 @@ "signalQuality": "جودة الإشارة", "signalQualitySubtitle": "جودة إشارة WiFi حسب نطاق التردد", "signalStrengthDbm": "{value} dBm", - "singlePortWithCount": "منفذ مفرد ({count})", "singleRouterSetupNoBackhaul": "إعداد بجهاز توجيه واحد — بدون نقل خلفي", "sizeBytes": "الحجم: {size} بايت ({mib} MiB)", "skipped": "تم التخطي", @@ -1033,7 +1031,6 @@ "trend": "الاتجاه", "trends": "الاتجاهات", "triggerPorts": "منافذ التشغيل", - "triggeringWithCount": "التشغيل ({count})", "txPower": "طاقة الإرسال", "type": "النوع", "typeAMessage": "اكتب رسالة...", diff --git a/lib/l10n/app_da.arb b/lib/l10n/app_da.arb index 3d88bd9b6..0d488140c 100644 --- a/lib/l10n/app_da.arb +++ b/lib/l10n/app_da.arb @@ -384,7 +384,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.", @@ -592,7 +592,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", @@ -802,7 +802,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", @@ -858,9 +858,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", @@ -972,7 +971,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", @@ -1033,7 +1031,6 @@ "trend": "Tendens", "trends": "Tendenser", "triggerPorts": "Udløserporte", - "triggeringWithCount": "Udløsning ({count})", "txPower": "Tx-effekt", "type": "Type", "typeAMessage": "Skriv en besked...", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 20181bb7e..323c08433 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -384,7 +384,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.", @@ -592,7 +592,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", @@ -802,7 +802,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", @@ -858,9 +858,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", @@ -972,7 +971,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", @@ -1033,7 +1031,6 @@ "trend": "Trend", "trends": "Trends", "triggerPorts": "Trigger-Ports", - "triggeringWithCount": "Triggering ({count})", "txPower": "Sendeleistung", "type": "Typ", "typeAMessage": "Nachricht eingeben...", diff --git a/lib/l10n/app_el.arb b/lib/l10n/app_el.arb index 4481ed2b1..ea52f75ad 100644 --- a/lib/l10n/app_el.arb +++ b/lib/l10n/app_el.arb @@ -382,7 +382,7 @@ "addDeviceManually": "Μη αυτόματη προσθήκη συσκευής", "addPortForwarding": "Προσθήκη προώθησης θύρας", "addPortRangeForwarding": "Προσθήκη προώθησης εύρους θυρών", - "addPortTriggering": "Προσθήκη ενεργοποίησης θύρας", + "addPortTriggering": "Προσθήκη ενεργοποίησης εύρους θυρών", "addedWidgetNamed": "Προστέθηκε {name}", "adding": "Προσθήκη…", "additionalFiltersOnlineOnly": "Τα πρόσθετα φίλτρα είναι διαθέσιμα μόνο για συνδεδεμένες συσκευές.", @@ -590,7 +590,7 @@ "dynamicFrequencySelection": "Δυναμική Επιλογή Συχνότητας (DFS)", "editPortForwarding": "Επεξεργασία προώθησης θύρας", "editPortRangeForwarding": "Επεξεργασία προώθησης εύρους θυρών", - "editPortTriggering": "Επεξεργασία ενεργοποίησης θύρας", + "editPortTriggering": "Επεξεργασία ενεργοποίησης εύρους θυρών", "editRule": "Επεξεργασία κανόνα", "editStaticRoute": "Επεξεργασία στατικής διαδρομής", "editTimeSettings": "Επεξεργασία ρυθμίσεων ώρας", @@ -802,7 +802,7 @@ "noLogFilesAvailable": "Δεν υπάρχουν διαθέσιμα αρχεία καταγραφής σε αυτόν τον δρομολογητή", "noPortMappingsConfigured": "Δεν έχουν διαμορφωθεί αντιστοιχίσεις θυρών", "noPortRangeRules": "Δεν έχουν διαμορφωθεί κανόνες προώθησης εύρους θυρών", - "noPortTriggeringRules": "Δεν έχουν διαμορφωθεί κανόνες ενεργοποίησης θύρας", + "noPortTriggeringRules": "Δεν έχουν διαμορφωθεί κανόνες ενεργοποίησης εύρους θυρών", "noPresetSelected": "Δεν έχει επιλεγεί προεπιλογή", "noReservationIpMayChange": "Δεν υπάρχει δέσμευση. Η IP ενδέχεται να αλλάξει κατά την επανασύνδεση.", "noSinglePortRules": "Δεν έχουν διαμορφωθεί κανόνες προώθησης μεμονωμένης θύρας", @@ -858,9 +858,8 @@ "portMapping": "Αντιστοίχιση θυρών", "portMappingSubtitle": "Κανόνες προώθησης θυρών και διαμόρφωση DMZ", "portMustBe1To65535": "Η θύρα πρέπει να είναι 1-65535", - "portRangeWithCount": "Εύρος θυρών ({count})", "portRules": "Κανόνες θυρών", - "portTriggering": "Ενεργοποίηση θύρας", + "portTriggering": "Ενεργοποίηση εύρους θυρών", "ports": "Θύρες", "potentialIssues": "Πιθανά προβλήματα", "pppStatus": "Κατάσταση PPP", @@ -972,7 +971,6 @@ "signalQuality": "Ποιότητα σήματος", "signalQualitySubtitle": "Ποιότητα σήματος WiFi ανά ζώνη συχνοτήτων", "signalStrengthDbm": "{value} dBm", - "singlePortWithCount": "Μεμονωμένη θύρα ({count})", "singleRouterSetupNoBackhaul": "Εγκατάσταση μεμονωμένου δρομολογητή — χωρίς backhaul", "sizeBytes": "Μέγεθος: {size} byte ({mib} MiB)", "skipped": "Παραλείφθηκε", @@ -1033,7 +1031,6 @@ "trend": "Τάση", "trends": "Τάσεις", "triggerPorts": "Θύρες ενεργοποίησης", - "triggeringWithCount": "Ενεργοποίηση ({count})", "txPower": "Ισχύς Tx", "type": "Τύπος", "typeAMessage": "Πληκτρολογήστε ένα μήνυμα...", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 02cebec46..ccbc8e446 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1234,8 +1234,8 @@ "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": { @@ -1245,8 +1245,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)", @@ -1256,30 +1256,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", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 0881c02aa..69379eafb 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -384,7 +384,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.", @@ -592,7 +592,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", @@ -802,7 +802,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", @@ -858,9 +858,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", @@ -972,7 +971,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", @@ -1033,7 +1031,6 @@ "trend": "Tendencia", "trends": "Tendencias", "triggerPorts": "Puertos de activación", - "triggeringWithCount": "Activación ({count})", "txPower": "Potencia de Tx", "type": "Tipo", "typeAMessage": "Escriba un mensaje...", diff --git a/lib/l10n/app_es_ar.arb b/lib/l10n/app_es_ar.arb index 69e7a7793..aff20f924 100644 --- a/lib/l10n/app_es_ar.arb +++ b/lib/l10n/app_es_ar.arb @@ -384,7 +384,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.", @@ -592,7 +592,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", @@ -802,7 +802,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", @@ -858,9 +858,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", @@ -972,7 +971,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", @@ -1033,7 +1031,6 @@ "trend": "Tendencia", "trends": "Tendencias", "triggerPorts": "Puertos de activación", - "triggeringWithCount": "Activación ({count})", "txPower": "Potencia de transmisión", "type": "Tipo", "typeAMessage": "Escriba un mensaje...", diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb index adba4fe86..0f56eac09 100644 --- a/lib/l10n/app_fi.arb +++ b/lib/l10n/app_fi.arb @@ -382,7 +382,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.", @@ -590,7 +590,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", @@ -802,7 +802,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", @@ -858,9 +858,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", @@ -972,7 +971,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", @@ -1033,7 +1031,6 @@ "trend": "Trendi", "trends": "Trendit", "triggerPorts": "Laukaisuportit", - "triggeringWithCount": "Laukaisu ({count})", "txPower": "Lähetysteho", "type": "Tyyppi", "typeAMessage": "Kirjoita viesti...", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index fdd6b1769..5b15f40cf 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -384,7 +384,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.", @@ -592,7 +592,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", @@ -802,7 +802,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", @@ -858,9 +858,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", @@ -972,7 +971,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é", @@ -1033,7 +1031,6 @@ "trend": "Tendance", "trends": "Tendances", "triggerPorts": "Ports de déclenchement", - "triggeringWithCount": "Déclenchement ({count})", "txPower": "Puissance Tx", "type": "Type", "typeAMessage": "Saisissez un message...", diff --git a/lib/l10n/app_fr_ca.arb b/lib/l10n/app_fr_ca.arb index 18ceaeffc..b454556ba 100644 --- a/lib/l10n/app_fr_ca.arb +++ b/lib/l10n/app_fr_ca.arb @@ -384,7 +384,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.", @@ -592,7 +592,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", @@ -802,7 +802,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", @@ -858,9 +858,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", @@ -972,7 +971,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é", @@ -1033,7 +1031,6 @@ "trend": "Tendance", "trends": "Tendances", "triggerPorts": "Ports de déclenchement", - "triggeringWithCount": "Déclenchement ({count})", "txPower": "Puissance Tx", "type": "Type", "typeAMessage": "Tapez un message...", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 40d89f81e..09b5628d7 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -382,7 +382,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.", @@ -590,7 +590,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", @@ -802,7 +802,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", @@ -858,9 +858,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", @@ -972,7 +971,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", @@ -1033,7 +1031,6 @@ "trend": "Tren", "trends": "Tren", "triggerPorts": "Port Pemicu", - "triggeringWithCount": "Pemicu ({count})", "txPower": "Daya Tx", "type": "Tipe", "typeAMessage": "Ketik pesan...", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index cd35ef6d2..88cbe7bbf 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -384,7 +384,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.", @@ -592,7 +592,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", @@ -802,7 +802,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", @@ -858,9 +858,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", @@ -972,7 +971,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", @@ -1033,7 +1031,6 @@ "trend": "Andamento", "trends": "Andamenti", "triggerPorts": "Porte di trigger", - "triggeringWithCount": "Triggering ({count})", "txPower": "Potenza Tx", "type": "Tipo", "typeAMessage": "Scrivi un messaggio...", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index b644492c4..f43c7742e 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -385,7 +385,7 @@ "addDeviceManually": "デバイスを手動で追加", "addPortForwarding": "ポート転送を追加", "addPortRangeForwarding": "ポート範囲転送を追加", - "addPortTriggering": "ポートトリガーを追加", + "addPortTriggering": "ポート範囲トリガーを追加", "addedWidgetNamed": "{name} を追加しました", "adding": "追加中…", "additionalFiltersOnlineOnly": "追加のフィルターはオンラインのデバイスでのみ利用できます。", @@ -593,7 +593,7 @@ "dynamicFrequencySelection": "動的周波数選択 (DFS)", "editPortForwarding": "ポート転送を編集", "editPortRangeForwarding": "ポート範囲転送を編集", - "editPortTriggering": "ポートトリガーを編集", + "editPortTriggering": "ポート範囲トリガーを編集", "editRule": "ルールを編集", "editStaticRoute": "静的ルートを編集", "editTimeSettings": "時刻設定を編集", @@ -802,7 +802,7 @@ "noLogFilesAvailable": "このルーターに利用可能なログファイルはありません", "noPortMappingsConfigured": "ポートマッピングが設定されていません", "noPortRangeRules": "ポート範囲転送ルールが設定されていません", - "noPortTriggeringRules": "ポートトリガールールが設定されていません", + "noPortTriggeringRules": "ポート範囲トリガールールが設定されていません", "noPresetSelected": "プリセットが選択されていません", "noReservationIpMayChange": "予約なし。再接続時に IP が変わる場合があります。", "noSinglePortRules": "単一ポート転送ルールが設定されていません", @@ -858,9 +858,8 @@ "portMapping": "ポートマッピング", "portMappingSubtitle": "ポート転送ルールと DMZ 設定", "portMustBe1To65535": "ポートは 1〜65535 である必要があります", - "portRangeWithCount": "ポート範囲 ({count})", "portRules": "ポートルール", - "portTriggering": "ポートトリガー", + "portTriggering": "ポート範囲トリガー", "ports": "ポート", "potentialIssues": "潜在的な問題", "pppStatus": "PPP ステータス", @@ -972,7 +971,6 @@ "signalQuality": "信号品質", "signalQualitySubtitle": "周波数バンド別の WiFi 信号品質", "signalStrengthDbm": "{value} dBm", - "singlePortWithCount": "単一ポート ({count})", "singleRouterSetupNoBackhaul": "単一ルーター構成 — バックホールなし", "sizeBytes": "サイズ: {size} バイト ({mib} MiB)", "skipped": "スキップ", @@ -1033,7 +1031,6 @@ "trend": "傾向", "trends": "傾向", "triggerPorts": "トリガーポート", - "triggeringWithCount": "トリガー ({count})", "txPower": "送信電力", "type": "タイプ", "typeAMessage": "メッセージを入力...", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 8a2afde43..bf6a09e2c 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -382,7 +382,7 @@ "addDeviceManually": "장치 수동 추가", "addPortForwarding": "포트 포워딩 추가", "addPortRangeForwarding": "포트 범위 포워딩 추가", - "addPortTriggering": "포트 트리거링 추가", + "addPortTriggering": "포트 범위 트리거링 추가", "addedWidgetNamed": "{name} 추가됨", "adding": "추가하는 중…", "additionalFiltersOnlineOnly": "추가 필터는 온라인 장치에서만 사용할 수 있습니다.", @@ -590,7 +590,7 @@ "dynamicFrequencySelection": "동적 주파수 선택(DFS)", "editPortForwarding": "포트 포워딩 편집", "editPortRangeForwarding": "포트 범위 포워딩 편집", - "editPortTriggering": "포트 트리거링 편집", + "editPortTriggering": "포트 범위 트리거링 편집", "editRule": "규칙 편집", "editStaticRoute": "고정 경로 편집", "editTimeSettings": "시간 설정 편집", @@ -802,7 +802,7 @@ "noLogFilesAvailable": "이 라우터에 사용할 수 있는 로그 파일이 없습니다", "noPortMappingsConfigured": "구성된 포트 매핑이 없습니다", "noPortRangeRules": "구성된 포트 범위 포워딩 규칙이 없습니다", - "noPortTriggeringRules": "구성된 포트 트리거링 규칙이 없습니다", + "noPortTriggeringRules": "구성된 포트 범위 트리거링 규칙 없음", "noPresetSelected": "선택된 사전 설정이 없습니다", "noReservationIpMayChange": "예약 없음. 다시 연결하면 IP가 변경될 수 있습니다.", "noSinglePortRules": "구성된 단일 포트 포워딩 규칙이 없습니다", @@ -858,9 +858,8 @@ "portMapping": "포트 매핑", "portMappingSubtitle": "포트 포워딩 규칙 및 DMZ 구성", "portMustBe1To65535": "포트는 1-65535여야 합니다", - "portRangeWithCount": "포트 범위 ({count})", "portRules": "포트 규칙", - "portTriggering": "포트 트리거링", + "portTriggering": "포트 범위 트리거링", "ports": "포트", "potentialIssues": "잠재적 문제", "pppStatus": "PPP 상태", @@ -972,7 +971,6 @@ "signalQuality": "신호 품질", "signalQualitySubtitle": "주파수 대역별 WiFi 신호 품질", "signalStrengthDbm": "{value} dBm", - "singlePortWithCount": "단일 포트 ({count})", "singleRouterSetupNoBackhaul": "단일 라우터 설정 — 백홀 없음", "sizeBytes": "크기: {size} bytes ({mib} MiB)", "skipped": "건너뜀", @@ -1033,7 +1031,6 @@ "trend": "추세", "trends": "추세", "triggerPorts": "트리거 포트", - "triggeringWithCount": "트리거링 ({count})", "txPower": "Tx 출력", "type": "유형", "typeAMessage": "메시지를 입력하세요...", diff --git a/lib/l10n/app_nb.arb b/lib/l10n/app_nb.arb index 65b012118..c90a4c6c8 100644 --- a/lib/l10n/app_nb.arb +++ b/lib/l10n/app_nb.arb @@ -384,7 +384,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.", @@ -592,7 +592,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", @@ -802,7 +802,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", @@ -858,9 +858,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", @@ -972,7 +971,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", @@ -1033,7 +1031,6 @@ "trend": "Trend", "trends": "Trender", "triggerPorts": "Utløserporter", - "triggeringWithCount": "Utløsing ({count})", "txPower": "Tx-effekt", "type": "Type", "typeAMessage": "Skriv en melding...", diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 68db4f384..7ca05f321 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -384,7 +384,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.", @@ -592,7 +592,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", @@ -802,7 +802,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", @@ -858,9 +858,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", @@ -972,7 +971,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", @@ -1033,7 +1031,6 @@ "trend": "Trend", "trends": "Trends", "triggerPorts": "Triggerpoorten", - "triggeringWithCount": "Triggering ({count})", "txPower": "Tx-vermogen", "type": "Type", "typeAMessage": "Typ een bericht...", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 7b8e9f585..39dc7dfda 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -382,7 +382,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.", @@ -590,7 +590,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", @@ -802,7 +802,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", @@ -858,9 +858,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", @@ -972,7 +971,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", @@ -1033,7 +1031,6 @@ "trend": "Trend", "trends": "Trendy", "triggerPorts": "Porty wyzwalające", - "triggeringWithCount": "Wyzwalanie ({count})", "txPower": "Moc nadawania", "type": "Typ", "typeAMessage": "Wpisz wiadomość...", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 12e52f974..4d56a00c3 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -384,7 +384,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.", @@ -592,7 +592,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", @@ -802,7 +802,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", @@ -858,9 +858,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", @@ -972,7 +971,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", @@ -1033,7 +1031,6 @@ "trend": "Tendência", "trends": "Tendências", "triggerPorts": "Portas de acionamento", - "triggeringWithCount": "Acionamento ({count})", "txPower": "Potência de Tx", "type": "Tipo", "typeAMessage": "Digite uma mensagem...", diff --git a/lib/l10n/app_pt_pt.arb b/lib/l10n/app_pt_pt.arb index 620606780..0a0523818 100644 --- a/lib/l10n/app_pt_pt.arb +++ b/lib/l10n/app_pt_pt.arb @@ -384,7 +384,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.", @@ -592,7 +592,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", @@ -802,7 +802,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", @@ -858,9 +858,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", @@ -972,7 +971,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", @@ -1033,7 +1031,6 @@ "trend": "Tendência", "trends": "Tendências", "triggerPorts": "Portas de acionamento", - "triggeringWithCount": "Acionamento ({count})", "txPower": "Potência Tx", "type": "Tipo", "typeAMessage": "Escreva uma mensagem...", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 29876fd8b..c2da913ae 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -382,7 +382,7 @@ "addDeviceManually": "Добавить устройство вручную", "addPortForwarding": "Добавить переадресацию портов", "addPortRangeForwarding": "Добавить переадресацию диапазона портов", - "addPortTriggering": "Добавить триггер портов", + "addPortTriggering": "Добавить триггер диапазона портов", "addedWidgetNamed": "Добавлено: {name}", "adding": "Добавление…", "additionalFiltersOnlineOnly": "Дополнительные фильтры доступны только для устройств в сети.", @@ -590,7 +590,7 @@ "dynamicFrequencySelection": "Динамический выбор частоты (DFS)", "editPortForwarding": "Редактировать переадресацию портов", "editPortRangeForwarding": "Редактировать переадресацию диапазона портов", - "editPortTriggering": "Редактировать триггер портов", + "editPortTriggering": "Редактировать триггер диапазона портов", "editRule": "Редактировать правило", "editStaticRoute": "Редактировать статический маршрут", "editTimeSettings": "Редактировать настройки времени", @@ -802,7 +802,7 @@ "noLogFilesAvailable": "На этом маршрутизаторе нет доступных файлов журнала", "noPortMappingsConfigured": "Сопоставления портов не настроены", "noPortRangeRules": "Правила переадресации диапазона портов не настроены", - "noPortTriggeringRules": "Правила триггера портов не настроены", + "noPortTriggeringRules": "Правила триггера диапазона портов не настроены", "noPresetSelected": "Предустановка не выбрана", "noReservationIpMayChange": "Нет резервирования. IP может измениться при переподключении.", "noSinglePortRules": "Правила переадресации одиночного порта не настроены", @@ -858,9 +858,8 @@ "portMapping": "Сопоставление портов", "portMappingSubtitle": "Правила переадресации портов и конфигурация DMZ", "portMustBe1To65535": "Порт должен быть от 1 до 65535", - "portRangeWithCount": "Диапазон портов ({count})", "portRules": "Правила портов", - "portTriggering": "Триггер портов", + "portTriggering": "Триггер диапазона портов", "ports": "Порты", "potentialIssues": "Возможные проблемы", "pppStatus": "Статус PPP", @@ -972,7 +971,6 @@ "signalQuality": "Качество сигнала", "signalQualitySubtitle": "Качество сигнала WiFi по частотным диапазонам", "signalStrengthDbm": "{value} dBm", - "singlePortWithCount": "Одиночный порт ({count})", "singleRouterSetupNoBackhaul": "Конфигурация с одним маршрутизатором — без транзита", "sizeBytes": "Размер: {size} байт ({mib} MiB)", "skipped": "Пропущено", @@ -1033,7 +1031,6 @@ "trend": "Тенденция", "trends": "Тенденции", "triggerPorts": "Триггерные порты", - "triggeringWithCount": "Триггеры ({count})", "txPower": "Мощность передачи", "type": "Тип", "typeAMessage": "Введите сообщение...", diff --git a/lib/l10n/app_sv.arb b/lib/l10n/app_sv.arb index 3bc87b05a..8642fa36f 100644 --- a/lib/l10n/app_sv.arb +++ b/lib/l10n/app_sv.arb @@ -384,7 +384,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.", @@ -592,7 +592,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", @@ -802,7 +802,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", @@ -858,9 +858,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", @@ -972,7 +971,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", @@ -1033,7 +1031,6 @@ "trend": "Trend", "trends": "Trender", "triggerPorts": "Triggerportar", - "triggeringWithCount": "Triggning ({count})", "txPower": "Tx-effekt", "type": "Typ", "typeAMessage": "Skriv ett meddelande...", diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index c583f1cf4..5eb6a7ba6 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -382,7 +382,7 @@ "addDeviceManually": "เพิ่มอุปกรณ์ด้วยตนเอง", "addPortForwarding": "เพิ่มการส่งต่อพอร์ต", "addPortRangeForwarding": "เพิ่มการส่งต่อช่วงพอร์ต", - "addPortTriggering": "เพิ่มการทริกเกอร์พอร์ต", + "addPortTriggering": "เพิ่มการทริกเกอร์ช่วงพอร์ต", "addedWidgetNamed": "เพิ่ม {name} แล้ว", "adding": "กำลังเพิ่ม…", "additionalFiltersOnlineOnly": "ตัวกรองเพิ่มเติมใช้ได้เฉพาะกับอุปกรณ์ที่ออนไลน์เท่านั้น", @@ -590,7 +590,7 @@ "dynamicFrequencySelection": "Dynamic Frequency Selection (DFS)", "editPortForwarding": "แก้ไขการส่งต่อพอร์ต", "editPortRangeForwarding": "แก้ไขการส่งต่อช่วงพอร์ต", - "editPortTriggering": "แก้ไขการทริกเกอร์พอร์ต", + "editPortTriggering": "แก้ไขการทริกเกอร์ช่วงพอร์ต", "editRule": "แก้ไขกฎ", "editStaticRoute": "แก้ไขเส้นทางแบบสแตติก", "editTimeSettings": "แก้ไขการตั้งค่าเวลา", @@ -802,7 +802,7 @@ "noLogFilesAvailable": "ไม่มีไฟล์บันทึกบนเราเตอร์นี้", "noPortMappingsConfigured": "ยังไม่มีการกำหนดค่าการแมปพอร์ต", "noPortRangeRules": "ยังไม่มีการกำหนดค่ากฎการส่งต่อช่วงพอร์ต", - "noPortTriggeringRules": "ยังไม่มีการกำหนดค่ากฎการทริกเกอร์พอร์ต", + "noPortTriggeringRules": "ไม่มีการกำหนดค่ากฎการทริกเกอร์ช่วงพอร์ต", "noPresetSelected": "ยังไม่ได้เลือกพรีเซ็ต", "noReservationIpMayChange": "ไม่มีการสำรอง IP อาจเปลี่ยนเมื่อเชื่อมต่อใหม่", "noSinglePortRules": "ยังไม่มีการกำหนดค่ากฎการส่งต่อพอร์ตเดี่ยว", @@ -858,9 +858,8 @@ "portMapping": "การแมปพอร์ต", "portMappingSubtitle": "กฎการส่งต่อพอร์ตและการกำหนดค่า DMZ", "portMustBe1To65535": "พอร์ตต้องอยู่ระหว่าง 1-65535", - "portRangeWithCount": "ช่วงพอร์ต ({count})", "portRules": "กฎพอร์ต", - "portTriggering": "การทริกเกอร์พอร์ต", + "portTriggering": "การทริกเกอร์ช่วงพอร์ต", "ports": "พอร์ต", "potentialIssues": "ปัญหาที่อาจเกิดขึ้น", "pppStatus": "สถานะ PPP", @@ -972,7 +971,6 @@ "signalQuality": "คุณภาพสัญญาณ", "signalQualitySubtitle": "คุณภาพสัญญาณ WiFi ตามแบนด์ความถี่", "signalStrengthDbm": "{value} dBm", - "singlePortWithCount": "พอร์ตเดี่ยว ({count})", "singleRouterSetupNoBackhaul": "การตั้งค่าเราเตอร์เดี่ยว — ไม่มี backhaul", "sizeBytes": "ขนาด: {size} ไบต์ ({mib} MiB)", "skipped": "ข้ามแล้ว", @@ -1033,7 +1031,6 @@ "trend": "แนวโน้ม", "trends": "แนวโน้ม", "triggerPorts": "พอร์ตทริกเกอร์", - "triggeringWithCount": "การทริกเกอร์ ({count})", "txPower": "กำลังส่ง", "type": "ประเภท", "typeAMessage": "พิมพ์ข้อความ...", diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index b35f65568..aad9497d5 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -382,7 +382,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.", @@ -590,7 +590,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", @@ -802,7 +802,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", @@ -858,9 +858,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", @@ -972,7 +971,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ı", @@ -1033,7 +1031,6 @@ "trend": "Eğilim", "trends": "Eğilimler", "triggerPorts": "Tetikleme Portları", - "triggeringWithCount": "Tetikleme ({count})", "txPower": "Tx Gücü", "type": "Tür", "typeAMessage": "Bir mesaj yazın...", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 6fe35c0c7..240ac0f72 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -382,7 +382,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.", @@ -590,7 +590,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", @@ -802,7 +802,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", @@ -858,9 +858,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", @@ -972,7 +971,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", @@ -1033,7 +1031,6 @@ "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...", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 76be733ee..28d223415 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -385,7 +385,7 @@ "addDeviceManually": "手动添加设备", "addPortForwarding": "添加端口转发", "addPortRangeForwarding": "添加端口范围转发", - "addPortTriggering": "添加端口触发", + "addPortTriggering": "添加端口范围触发", "addedWidgetNamed": "已添加 {name}", "adding": "添加中…", "additionalFiltersOnlineOnly": "其他筛选条件仅适用于在线设备。", @@ -593,7 +593,7 @@ "dynamicFrequencySelection": "动态频率选择 (DFS)", "editPortForwarding": "编辑端口转发", "editPortRangeForwarding": "编辑端口范围转发", - "editPortTriggering": "编辑端口触发", + "editPortTriggering": "编辑端口范围触发", "editRule": "编辑规则", "editStaticRoute": "编辑静态路由", "editTimeSettings": "编辑时间设置", @@ -802,7 +802,7 @@ "noLogFilesAvailable": "此路由器上没有可用的日志文件", "noPortMappingsConfigured": "未配置端口映射", "noPortRangeRules": "未配置端口范围转发规则", - "noPortTriggeringRules": "未配置端口触发规则", + "noPortTriggeringRules": "未配置端口范围触发规则", "noPresetSelected": "未选择预设", "noReservationIpMayChange": "无保留。重新连接时 IP 可能更改。", "noSinglePortRules": "未配置单端口转发规则", @@ -858,9 +858,8 @@ "portMapping": "端口映射", "portMappingSubtitle": "端口转发规则和 DMZ 配置", "portMustBe1To65535": "端口必须为 1-65535", - "portRangeWithCount": "端口范围({count})", "portRules": "端口规则", - "portTriggering": "端口触发", + "portTriggering": "端口范围触发", "ports": "端口", "potentialIssues": "潜在问题", "pppStatus": "PPP 状态", @@ -972,7 +971,6 @@ "signalQuality": "信号质量", "signalQualitySubtitle": "按频段划分的 WiFi 信号质量", "signalStrengthDbm": "{value} dBm", - "singlePortWithCount": "单端口({count})", "singleRouterSetupNoBackhaul": "单路由器设置——无回程", "sizeBytes": "大小:{size} 字节({mib} MiB)", "skipped": "已跳过", @@ -1033,7 +1031,6 @@ "trend": "趋势", "trends": "趋势", "triggerPorts": "触发端口", - "triggeringWithCount": "触发({count})", "txPower": "发射功率", "type": "类型", "typeAMessage": "输入消息…", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 747e173ed..cb878d5f5 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -385,7 +385,7 @@ "addDeviceManually": "手動新增裝置", "addPortForwarding": "新增連接埠轉送", "addPortRangeForwarding": "新增連接埠範圍轉送", - "addPortTriggering": "新增連接埠觸發", + "addPortTriggering": "新增連接埠範圍觸發", "addedWidgetNamed": "已新增 {name}", "adding": "正在新增…", "additionalFiltersOnlineOnly": "其他篩選條件僅適用於連線中的裝置。", @@ -593,7 +593,7 @@ "dynamicFrequencySelection": "動態頻率選擇 (DFS)", "editPortForwarding": "編輯連接埠轉送", "editPortRangeForwarding": "編輯連接埠範圍轉送", - "editPortTriggering": "編輯連接埠觸發", + "editPortTriggering": "編輯連接埠範圍觸發", "editRule": "編輯規則", "editStaticRoute": "編輯靜態路由", "editTimeSettings": "編輯時間設定", @@ -802,7 +802,7 @@ "noLogFilesAvailable": "此路由器上沒有可用的記錄檔", "noPortMappingsConfigured": "未設定任何連接埠對應", "noPortRangeRules": "未設定任何連接埠範圍轉送規則", - "noPortTriggeringRules": "未設定任何連接埠觸發規則", + "noPortTriggeringRules": "未設定連接埠範圍觸發規則", "noPresetSelected": "未選擇任何預設組合", "noReservationIpMayChange": "沒有保留。IP 在重新連線時可能會變更。", "noSinglePortRules": "未設定任何單一連接埠轉送規則", @@ -858,9 +858,8 @@ "portMapping": "連接埠對應", "portMappingSubtitle": "連接埠轉送規則與 DMZ 設定", "portMustBe1To65535": "連接埠必須介於 1-65535", - "portRangeWithCount": "連接埠範圍 ({count})", "portRules": "連接埠規則", - "portTriggering": "連接埠觸發", + "portTriggering": "連接埠範圍觸發", "ports": "連接埠", "potentialIssues": "潛在問題", "pppStatus": "PPP 狀態", @@ -972,7 +971,6 @@ "signalQuality": "訊號品質", "signalQualitySubtitle": "依頻段的 WiFi 訊號品質", "signalStrengthDbm": "{value} dBm", - "singlePortWithCount": "單一連接埠 ({count})", "singleRouterSetupNoBackhaul": "單一路由器設定 — 沒有回程", "sizeBytes": "大小:{size} bytes ({mib} MiB)", "skipped": "已略過", @@ -1033,7 +1031,6 @@ "trend": "趨勢", "trends": "趨勢", "triggerPorts": "觸發連接埠", - "triggeringWithCount": "觸發 ({count})", "txPower": "Tx 功率", "type": "類型", "typeAMessage": "輸入訊息...", 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 f7a7e3768..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), 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 07b455b7a..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), 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 64b1bea73..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( 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(), From cdb3167f608f89773ad622508b9859e40decbd29 Mon Sep 17 00:00:00 2001 From: AustinChangLinksys <79675086+AustinChangLinksys@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:17:10 +0800 Subject: [PATCH 47/56] fix(auth): unify USP login check so Remote Assistance can open Wi-Fi/Internet Settings (#1119) (#1122) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(auth): unify USP login check so Remote Assistance can open Wi-Fi/Internet Settings (#1119) In Remote Assistance mode the WASM USP client is pre-authorized via authToken, so `usp.isAuthenticated` stays false by design. The Wi-Fi and Internet Settings providers gated their fetch on that raw transport flag, short-circuiting with "You are not signed in" even though the backend serves data fine. The router already guards all /usp routes on loginType (RA-aware) and 9 sibling feature providers have no such gate, making these two gates both redundant and wrong. - Add `AuthState.isRemoteAssistance` as the canonical login-intent check, replacing scattered `loginType == LoginType.remote` comparisons. - Add `uspAuthReadyProvider` as the single source of truth for "USP layer is authorized to serve data" (RA || usp.isAuthenticated), documenting that usp.isAuthenticated is non-reactive. - Remove the raw isAuthenticated fetch gate in the Wi-Fi and Internet Settings providers (keep the usp == null guard). This fixes #1119. - Converge dashboard_orchestrator, router_provider and the RA session guard onto the new getter; the orchestrator keeps direct usp.isAuthenticated reads because it observes restoreSession flips. - Tests: repurpose the internet "unauthenticated" test into an RA-bypass proof, add wifi RA-bypass + usp==null tests, and add unit tests for the new getter and provider. Follow-up (out of scope): session_service.dart and sse_bootstrap still read the raw flag but are off the RA render path. Co-Authored-By: Claude Opus 4.8 * refactor(auth): drop unused uspAuthReadyProvider — auth stays a router concern (PR #1122 review W-1) The provider was introduced as a "single source of truth" for USP auth readiness but ended up with zero production consumers: wifi/internet fixed #1119 by removing their fetch gate (relying on the router guard) rather than routing through the provider, so it was dead code. Keeping it would also push the auth concern back into page providers (each page would ref.read it), which is the coupling we want to avoid. Auth is a navigation-layer concern: the router already gates every /usp route on loginType (RA-aware), so pages render data without an auth dependency — matching the 9 sibling USP settings providers that never had a gate. - Delete usp_auth_ready_provider.dart and its test. - Drop the stale provider reference from the orchestrator comment. - Keep AuthState.isRemoteAssistance: it is consumed by the router (the auth boundary) and by RA-strategy decisions in the orchestrator/session guard, not as a per-page login gate. Co-Authored-By: Claude Opus 4.8 * refactor(auth): add AuthState.isLoggedIn and converge login checks (PR #1122 review W-4) The app could identify the login *kind* (isRemoteAssistance) but had no canonical "is the user logged in" check — that was scattered as inline `loginType == LoginType.none` comparisons across 8 sites. Adding isLoggedIn completes the pair: isLoggedIn answers "logged in?", isRemoteAssistance answers "which kind?". - Add AuthState.isLoggedIn => loginType != LoginType.none, named to avoid confusion with the transport-layer usp.isAuthenticated (WASM flag). - Migrate the none-checks in app, router redirect, connection state, top bar, login view, root container and general settings widget to isLoggedIn. - One router site (redirectLogic) keeps the loginType local because it reuses the enum value later, not just the logged-in boolean. - Add isLoggedIn truth-table test. Co-Authored-By: Claude Opus 4.8 * docs(auth): clarify loginType is the source of truth, getters are shortcuts Document that AuthState.loginType is the single three-state source of truth and that isLoggedIn / isRemoteAssistance are derived named shortcuts, not a separate mechanism — so readers know when to use the enum vs the booleans. Co-Authored-By: Claude Opus 4.8 * test(auth): use const literal in fromJson test to clear analyzer info Fixes the pre-existing prefer_const_literals_to_create_immutables hint on the empty-map fromJson case, now that this file is touched by the auth getter tests. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- lib/app.dart | 3 +- lib/components/layouts/root_container.dart | 2 +- .../general_settings_widget.dart | 8 ++--- .../app_connection_state_provider.dart | 3 +- .../orchestrator/dashboard_orchestrator.dart | 4 +-- .../usp_internet_settings_notifier.dart | 10 +++--- lib/page/login/views/login_local_view.dart | 3 +- .../remote_assistance_session_guard.dart | 3 +- lib/page/shell/usp_top_bar.dart | 5 ++- .../providers/usp_wifi_settings_provider.dart | 15 ++++----- lib/providers/auth/auth_state.dart | 26 ++++++++++++++- lib/route/router_provider.dart | 14 ++++---- .../usp_internet_settings_notifier_test.dart | 33 ++++++++++++++++--- .../usp_wifi_settings_notifier_test.dart | 30 +++++++++++++++++ test/providers/auth/auth_state_test.dart | 17 +++++++++- 15 files changed, 131 insertions(+), 45 deletions(-) diff --git a/lib/app.dart b/lib/app.dart index 78e0a5412..a96ced3b9 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -233,8 +233,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; diff --git a/lib/components/layouts/root_container.dart b/lib/components/layouts/root_container.dart index b94346dea..352b307fa 100644 --- a/lib/components/layouts/root_container.dart +++ b/lib/components/layouts/root_container.dart @@ -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 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/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/page/dashboard/orchestrator/dashboard_orchestrator.dart b/lib/page/dashboard/orchestrator/dashboard_orchestrator.dart index 25b824373..9ae3ebb23 100644 --- a/lib/page/dashboard/orchestrator/dashboard_orchestrator.dart +++ b/lib/page/dashboard/orchestrator/dashboard_orchestrator.dart @@ -129,8 +129,8 @@ 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). // Use isRecovering: true because we handle auth failure via NotAuthenticatedError 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 ef16135b1..93d9aabed 100644 --- a/lib/page/internet_settings/providers/usp_internet_settings_notifier.dart +++ b/lib/page/internet_settings/providers/usp_internet_settings_notifier.dart @@ -85,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(); 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/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_top_bar.dart b/lib/page/shell/usp_top_bar.dart index 45895b3d5..786425c3d 100644 --- a/lib/page/shell/usp_top_bar.dart +++ b/lib/page/shell/usp_top_bar.dart @@ -70,9 +70,8 @@ class _UspTopBarState extends ConsumerState with DebugObserver { Row( mainAxisSize: MainAxisSize.min, children: [ - if (ref.watch(authProvider.select((v) => - v.value?.loginType != null && - v.value?.loginType != LoginType.none)) && + if (ref.watch(authProvider + .select((v) => v.value?.isLoggedIn ?? false)) && (ref.watch(appsCapabilityProvider).valueOrNull ?? false)) Tooltip( 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 b63e899de..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. diff --git a/lib/providers/auth/auth_state.dart b/lib/providers/auth/auth_state.dart index c1d57a23e..52738dac6 100644 --- a/lib/providers/auth/auth_state.dart +++ b/lib/providers/auth/auth_state.dart @@ -14,7 +14,13 @@ class AuthState extends Equatable { /// 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({ @@ -27,6 +33,24 @@ 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 = diff --git a/lib/route/router_provider.dart b/lib/route/router_provider.dart index 28f83e849..a7c43ec05 100644 --- a/lib/route/router_provider.dart +++ b/lib/route/router_provider.dart @@ -140,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(); } @@ -159,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(); @@ -221,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/test/page/internet_settings/providers/usp_internet_settings_notifier_test.dart b/test/page/internet_settings/providers/usp_internet_settings_notifier_test.dart index 301afeacd..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 @@ -421,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); @@ -431,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/wifi_settings/providers/usp_wifi_settings_notifier_test.dart b/test/page/wifi_settings/providers/usp_wifi_settings_notifier_test.dart index 7053601cf..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 // ----------------------------------------------------------------------- diff --git a/test/providers/auth/auth_state_test.dart b/test/providers/auth/auth_state_test.dart index d94e64ab2..a414fb104 100644 --- a/test/providers/auth/auth_state_test.dart +++ b/test/providers/auth/auth_state_test.dart @@ -47,7 +47,7 @@ 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); }); @@ -62,5 +62,20 @@ void main() { 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); + }); }); } From 8068794405e0f38d0cdb9b4c10a12c3900532657 Mon Sep 17 00:00:00 2001 From: AustinChangLinksys <79675086+AustinChangLinksys@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:17:33 +0800 Subject: [PATCH 48/56] fix(dashboard): upgrade UI Kit to v2.28.0 to clamp traffic curve at baseline (#1113) (#1123) Bump ui_kit_library and generative_ui from v2.27.0 to v2.28.0. v2.28.0 adds AppLineChart.preventCurveOverShooting (default true) which constrains cubic-bezier smoothing so the curved, filled line cannot dip below the lowest data point. This fixes the Traffic Monitor card whose upload/download curve could overshoot below the y=0 baseline in near-zero troughs. Co-authored-by: Claude Opus 4.8 --- pubspec.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pubspec.yaml b/pubspec.yaml index 69253db6d..e9dcc853f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -59,11 +59,11 @@ dependencies: ui_kit_library: git: url: https://github.com/linksys/privacyGUI-UI-kit.git - ref: v2.27.0 + ref: v2.28.0 generative_ui: git: url: https://github.com/linksys/privacyGUI-UI-kit.git - ref: v2.27.0 + ref: v2.28.0 path: generative_ui flutter_blue_plus: ^1.4.0 crypto: ^3.0.2 From 1f71a44bd11d8420aaf95a5147c5f06d07aa2c03 Mon Sep 17 00:00:00 2001 From: AustinChangLinksys <79675086+AustinChangLinksys@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:18:08 +0800 Subject: [PATCH 49/56] fix(wifi): hide DFS channels when DFS disabled + dedup channel parser (#1025, #1038) (#1124) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(wifi): hide DFS channels when DFS disabled; dedup channel parser (#1025, #1038) when DFS (IEEE 802.11h) was disabled. Firmware leaves them in PossibleChannels regardless of DFS state and TR-181 exposes no DFS-vs-non-DFS field, so the client now classifies and filters DFS channels itself: - WiFi Settings filters in the service (buildWifiNetworks), before computing per-bandwidth lists, so the dropdown and "N channels available" counts agree. - Dashboard threads a per-radio isDfsEnabled flag onto WifiRadioUIModel and the channel dialog filters at display time. single hardened parsePossibleChannels in lib/core/utils/wifi_channel.dart, and converge both services onto it. The shared copy carries the hardened logic (malformed-range guard + "0" sentinel filtering) as the baseline. No-op guard: when the radio's current channel is a DFS channel that the firmware left set while DFS is off, it is filtered out of the option list and the editor opens on Auto. Confirming without moving the selection now compares against the initial dropdown selection (not the radio's stored channel), so it is correctly treated as a no-op and issues no mutation. Co-Authored-By: Claude Opus 4.8 * fix(wifi): move radios off DFS channel when DFS is disabled (#1025) When DFS (IEEE 802.11h) is disabled from the Advanced tab, a radio manually parked on a DFS channel (e.g. 5 GHz ch 100) stays there — SSH-verified that the firmware does not vacate the channel on its own, leaving the radio on an illegal channel with no radar detection. On save, when DFS is being turned off, any radio currently on a manual DFS channel now also gets AutoChannelEnable=true in the same set() call, so the firmware reselects a legal non-DFS channel. Radios already on auto-channel, on non-DFS channels, or on non-5 GHz bands are untouched. Reuses isDfsChannel() from wifi_channel.dart for the classification. Co-Authored-By: Claude Opus 4.8 * refactor(wifi): address PR #1124 review — dedup path helper, parse set result, doc DFS contracts Follow-up to the automated review on PR #1124. - W-6: extract the 4 identical `_ensureTrailingDot`/`_withTrailingDot` copies (ethernet + 2 wifi services + advanced provider) into a single shared `ensureTrailingDot` in lib/core/utils/tr181_path.dart. - W-3: parse the USP `set()` result in UspWifiAdvancedService.setIeee80211hEnabled via UspResultParser, throwing UspPartialFailureError / UspCompleteFailureError on partial/complete firmware rejection (mirrors the settings-service pattern). A partial rejection of the forced AutoChannelEnable write is no longer silently swallowed. Adds partial/complete-failure tests and updates the existing set() stubs to a success-shaped map. - W-2: document that WifiRadioUIModel.possibleChannels is raw (DFS filtering happens at display time in WifiChannelDialog on the dashboard path). - W-5: note that dfsChannels5GHz is the US/FCC (UNII-2A/2C) set — extend per regulatory domain if multi-market support is required. W-1 (no-op guard not auto-correcting a pre-existing DFS-parked radio) is intentionally not changed — it would contradict the deliberate "view-only Apply must not trigger a write" design and reintroduce a spurious radio restart. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- lib/core/utils/tr181_path.dart | 10 ++ lib/core/utils/wifi_channel.dart | 67 ++++++++ .../_shared/models/wifi_radio_ui_model.dart | 15 ++ .../views/dialogs/wifi_channel_dialog.dart | 45 +++--- .../services/usp_ethernet_data_service.dart | 10 +- .../providers/usp_wifi_advanced_provider.dart | 28 +++- .../services/usp_wifi_advanced_service.dart | 33 +++- .../services/usp_wifi_data_service.dart | 71 +++----- .../services/usp_wifi_settings_service.dart | 58 +++---- .../views/components/wifi_network_card.dart | 7 +- test/core/utils/wifi_channel_test.dart | 124 ++++++++++++++ .../dialogs/wifi_channel_dialog_test.dart | 115 ++++++++++++- .../usp_wifi_advanced_notifier_test.dart | 152 +++++++++++++++++- .../usp_wifi_advanced_service_test.dart | 99 +++++++++++- .../services/usp_wifi_data_service_test.dart | 16 +- .../usp_wifi_settings_service_test.dart | 97 ++++++++++- 16 files changed, 813 insertions(+), 134 deletions(-) create mode 100644 lib/core/utils/tr181_path.dart create mode 100644 lib/core/utils/wifi_channel.dart create mode 100644 test/core/utils/wifi_channel_test.dart 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/page/_shared/models/wifi_radio_ui_model.dart b/lib/page/_shared/models/wifi_radio_ui_model.dart index 8b96c8981..b7780b170 100644 --- a/lib/page/_shared/models/wifi_radio_ui_model.dart +++ b/lib/page/_shared/models/wifi_radio_ui_model.dart @@ -19,8 +19,21 @@ class WifiRadioUIModel extends Equatable with DiagnosticLoggable { /// 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; @@ -35,6 +48,7 @@ class WifiRadioUIModel extends Equatable with DiagnosticLoggable { required this.channelBandwidth, required this.supportedStandards, this.possibleChannels = const [], + this.isDfsEnabled = false, this.accessPoints = const [], }); @@ -72,6 +86,7 @@ class WifiRadioUIModel extends Equatable with DiagnosticLoggable { 'channelBandwidth': channelBandwidth, 'supportedStandards': supportedStandards, 'possibleChannels': possibleChannels, + 'isDfsEnabled': isDfsEnabled, 'accessPoints': accessPoints, }; } diff --git a/lib/page/dashboard/views/dialogs/wifi_channel_dialog.dart b/lib/page/dashboard/views/dialogs/wifi_channel_dialog.dart index de45f506d..bacf8e6b6 100644 --- a/lib/page/dashboard/views/dialogs/wifi_channel_dialog.dart +++ b/lib/page/dashboard/views/dialogs/wifi_channel_dialog.dart @@ -1,4 +1,5 @@ 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'; @@ -27,6 +28,13 @@ class _WifiChannelDialogState extends State { /// 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; @@ -37,22 +45,22 @@ class _WifiChannelDialogState extends State { @override void initState() { super.initState(); - _channels = widget.radio.possibleChannels; + // 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; } - /// 5 GHz DFS channels (IEEE 802.11h). Used to annotate options with "· DFS". - static const _dfsChannels5 = { - 52, 56, 60, 64, // - 100, 104, 108, 112, 116, 120, 124, 128, 132, 136, 140, 144, - }; - - bool _isDfs(int channel) => - widget.radio.band == '5GHz' && _dfsChannels5.contains(channel); + bool _isDfs(int channel) => isDfsChannel(channel, band: widget.radio.band); String _labelFor(int value) { if (value == _autoValue) return loc(context).channelAutoRecommended; @@ -138,20 +146,21 @@ class _WifiChannelDialogState extends State { } 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; - // AC4: selection equal to the stored value is a no-op — return null so the - // caller issues no mutation. - final unchanged = autoChannel == widget.radio.autoChannelEnable && - (autoChannel || channel == widget.radio.channel); - if (unchanged) { - Navigator.of(context).pop(); - return; - } - Navigator.of(context).pop((channel: channel, autoChannel: autoChannel)); } } 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 b838e37e6..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,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/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'; @@ -129,11 +130,11 @@ class UspEthernetDataService { 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 { @@ -214,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/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/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 67247a597..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,6 +9,8 @@ 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'; @@ -158,14 +160,14 @@ 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 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), + 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 @@ -181,18 +183,18 @@ class UspWifiDataService { 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)); + 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. @@ -216,7 +218,8 @@ class UspWifiDataService { autoChannelEnable: radio.autoChannelEnable, channelBandwidth: radio.operatingChannelBandwidth, supportedStandards: radio.supportedStandards, - possibleChannels: _parsePossibleChannels(radio.possibleChannels), + possibleChannels: parsePossibleChannels(radio.possibleChannels), + isDfsEnabled: radio.ieee80211hEnabled, accessPoints: apModels, ); }).toList(); @@ -326,14 +329,14 @@ 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), }; @@ -342,18 +345,18 @@ class UspWifiDataService { 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); @@ -385,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'; @@ -398,39 +396,6 @@ class UspWifiDataService { return rawBand; } - /// 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] - static 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; - } - /// Builds a BSSID → band mapping from WiFi SSID and Radio data. /// /// Used by [MeshTopologyBuilder] to determine band for clients on slave nodes @@ -444,7 +409,7 @@ class UspWifiDataService { // Build Radio path → band lookup final bandByRadioPath = {}; for (final radio in radios.items) { - final path = _ensureTrailingDot(radio.instancePath); + final path = ensureTrailingDot(radio.instancePath); bandByRadioPath[path] = _normalizeBand(radio.operatingFrequencyBand); } @@ -454,7 +419,7 @@ class UspWifiDataService { final bssid = ssid.bssid.trim().toUpperCase(); if (bssid.isEmpty) continue; - final radioPath = _ensureTrailingDot(ssid.lowerLayers); + final radioPath = ensureTrailingDot(ssid.lowerLayers); final band = bandByRadioPath[radioPath]; if (band != null && band.isNotEmpty) { result[bssid] = band; 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 bfe79f5af..83fe62bce 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'; @@ -45,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: ' @@ -61,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}: ' @@ -83,8 +85,16 @@ class UspWifiSettingsService { 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 ?? ''); @@ -564,10 +574,10 @@ class UspWifiSettingsService { if (ssidPaths.isEmpty) return 0; // Resolve AccessPoint paths whose SSIDReference points at a matched SSID. - final matchedSsidPathSet = ssidPaths.map(_ensureTrailingDot).toSet(); + final matchedSsidPathSet = ssidPaths.map(ensureTrailingDot).toSet(); final apPaths = accessPoints.items .where((ap) => - matchedSsidPathSet.contains(_ensureTrailingDot(ap.ssidReference))) + matchedSsidPathSet.contains(ensureTrailingDot(ap.ssidReference))) .map((ap) => ap.instancePath) .toList(); @@ -642,38 +652,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 18119fd0d..c9782248c 100644 --- a/lib/page/wifi_settings/views/components/wifi_network_card.dart +++ b/lib/page/wifi_settings/views/components/wifi_network_card.dart @@ -465,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, @@ -485,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/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/page/dashboard/views/dialogs/wifi_channel_dialog_test.dart b/test/page/dashboard/views/dialogs/wifi_channel_dialog_test.dart index 9d6ef64f6..63df54b61 100644 --- a/test/page/dashboard/views/dialogs/wifi_channel_dialog_test.dart +++ b/test/page/dashboard/views/dialogs/wifi_channel_dialog_test.dart @@ -18,6 +18,7 @@ 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( @@ -31,6 +32,7 @@ WifiRadioUIModel _radio({ channelBandwidth: '80MHz', supportedStandards: 'ax', possibleChannels: possibleChannels, + isDfsEnabled: isDfsEnabled, ); } @@ -138,8 +140,9 @@ void main() { expect(captured!.autoChannel, isTrue); }); - testWidgets('AC5: stored channel not in possibleChannels defaults to Auto', - (t) async { + 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( @@ -162,10 +165,10 @@ void main() { await t.tap(find.text('Apply')); await t.pumpAndSettle(); - // Radio was NOT auto originally, now shows Auto -> this is a real change. + // 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, isNotNull); - expect(captured!.autoChannel, isTrue); + expect(captured, isNull); }); testWidgets( @@ -236,6 +239,108 @@ void main() { 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 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/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 cb1964bef..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 @@ -117,7 +117,7 @@ void _stubAllFetches(MockUspClient mockUsp) { } /// Stubs fetches for a single 5 GHz radio whose `PossibleChannels` value is -/// [possibleChannels]. Used to exercise `_parsePossibleChannels` (private) via +/// [possibleChannels]. Used to exercise the shared `parsePossibleChannels` via /// the public `fetch()` entry point. void _stubRadioWithPossibleChannels( MockUspClient mockUsp, @@ -234,6 +234,18 @@ 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); @@ -259,7 +271,7 @@ void main() { // ------------------------------------------------------------------------- // PossibleChannels parsing — range notation, sentinels, malformed tokens - // (W-3 / W-4). Exercises the private _parsePossibleChannels via fetch(). + // (W-3 / W-4). Exercises the shared parsePossibleChannels via fetch(). // ------------------------------------------------------------------------- group('PossibleChannels parsing', () { 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 6da8e59ef..eb166cffa 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: [ From b9c33cd6303be5eec062aa662c2002d09750ecb7 Mon Sep 17 00:00:00 2001 From: Hank Yu <52936029+HankYuLinksys@users.noreply.github.com> Date: Wed, 15 Jul 2026 10:07:31 +0800 Subject: [PATCH 50/56] feat(dashboard): show spinner on card toggle during mutation (#1055) (#1126) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(dashboard): show spinner on card toggle during mutation (#1055) - Add isLoading param to ToggleRow and NetworkRow components - Display CircularProgressIndicator in place of AppSwitch when loading - Pass isLoading to WiFi Networks, DHCP Reservations, Port Forwarding cards - Add missing wifi_networks card to Professional preset (17→18 cards) - Update test expectation for Professional preset card count * fix(dashboard): address PR review feedback for toggle spinner - Replace CircularProgressIndicator with AppLoader from ui_kit_library - Add instancePath null guard to DHCP delete button - Add instancePath null guard to port forwarding/triggering toggles - Disable share button during loading state - Hoist ref.watch to build() in WiFi networks card --- .../components/layout_blocks/row_blocks.dart | 44 ++++++++++++++----- .../models/usp_dashboard_preset.dart | 19 ++++---- .../cards/usp_dhcp_reservations_card.dart | 3 +- .../cards/usp_port_forwarding_card.dart | 6 ++- .../cards/usp_wifi_networks_card.dart | 7 +-- .../models/usp_dashboard_preset_test.dart | 4 +- .../providers/usp_layout_controller_test.dart | 4 +- 7 files changed, 59 insertions(+), 28 deletions(-) diff --git a/lib/page/_shared/components/layout_blocks/row_blocks.dart b/lib/page/_shared/components/layout_blocks/row_blocks.dart index b6d61c2f1..ae6710ca2 100644 --- a/lib/page/_shared/components/layout_blocks/row_blocks.dart +++ b/lib/page/_shared/components/layout_blocks/row_blocks.dart @@ -134,6 +134,8 @@ class NetworkBadgeWidget extends StatelessWidget { /// /// 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; @@ -141,6 +143,7 @@ class ToggleRow extends StatelessWidget { final String? subtitle; final Widget? trailing; final VoidCallback? onTap; + final bool isLoading; const ToggleRow({ super.key, @@ -150,6 +153,7 @@ class ToggleRow extends StatelessWidget { this.subtitle, this.trailing, this.onTap, + this.isLoading = false, }); @override @@ -162,11 +166,16 @@ class ToggleRow extends StatelessWidget { leading: SizedBox( width: 44, child: Center( - child: AppSwitch( - value: value, - onChanged: onChanged, - scale: 0.8, - ), + child: isLoading + ? SizedBox.square( + dimension: 26, + child: AppLoader(strokeWidth: 2), + ) + : AppSwitch( + value: value, + onChanged: onChanged, + scale: 0.8, + ), ), ), title: AppText.bodyMedium( @@ -195,6 +204,8 @@ class ToggleRow extends StatelessWidget { /// 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; @@ -203,6 +214,7 @@ class NetworkRow extends StatelessWidget { final int clientCount; final ValueChanged? onChanged; final VoidCallback? onShareTap; + final bool isLoading; const NetworkRow({ super.key, @@ -213,6 +225,7 @@ class NetworkRow extends StatelessWidget { required this.clientCount, this.onChanged, this.onShareTap, + this.isLoading = false, }); @override @@ -261,14 +274,25 @@ class NetworkRow extends StatelessWidget { trailing: Row( mainAxisSize: MainAxisSize.min, children: [ - if (isEnabled && onShareTap != null) ...[ + if (!isLoading && isEnabled && onShareTap != null) ...[ _ShareButton(onTap: onShareTap!), AppGap.sm(), ], - AppSwitch( - value: isEnabled, - onChanged: onChanged, - ), + isLoading + ? SizedBox( + width: 52, + height: 32, + child: Center( + child: SizedBox.square( + dimension: 24, + child: AppLoader(strokeWidth: 2), + ), + ), + ) + : AppSwitch( + value: isEnabled, + onChanged: onChanged, + ), ], ), ), 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/local_network/cards/usp_dhcp_reservations_card.dart b/lib/page/local_network/cards/usp_dhcp_reservations_card.dart index 61a50e87a..30212060a 100644 --- a/lib/page/local_network/cards/usp_dhcp_reservations_card.dart +++ b/lib/page/local_network/cards/usp_dhcp_reservations_card.dart @@ -73,6 +73,7 @@ class UspDhcpReservationsCard extends ConsumerWidget { DhcpReservationUIModel reservation, bool isLoading) { return ToggleRow( value: reservation.enable, + isLoading: isLoading, onChanged: isLoading || reservation.instancePath == null ? null : (value) => performUspMutation( @@ -87,7 +88,7 @@ class UspDhcpReservationsCard extends ConsumerWidget { subtitle: reservation.ip, trailing: AppIconButton( icon: AppIcon.font(Icons.delete_outline, size: 18), - onTap: isLoading + onTap: isLoading || reservation.instancePath == null ? null : () => _confirmDeleteDhcp(context, ref, reservation), ), 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 912d4f3e0..fc821555c 100644 --- a/lib/page/port_forwarding/cards/usp_port_forwarding_card.dart +++ b/lib/page/port_forwarding/cards/usp_port_forwarding_card.dart @@ -75,7 +75,8 @@ class UspPortForwardingCard extends ConsumerWidget { PortForwardingRuleUIModel rule, bool isLoading) { return ToggleRow( value: rule.enabled, - onChanged: isLoading + isLoading: isLoading, + onChanged: isLoading || rule.instancePath == null ? null : (value) => performUspMutation( context, @@ -95,7 +96,8 @@ class UspPortForwardingCard extends ConsumerWidget { PortTriggeringRuleUIModel trigger, bool isLoading) { return ToggleRow( value: trigger.enabled, - onChanged: isLoading + isLoading: isLoading, + onChanged: isLoading || trigger.instancePath == null ? null : (value) => performUspMutation( context, 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 b1548bf79..f0060c4db 100644 --- a/lib/page/wifi_settings/cards/usp_wifi_networks_card.dart +++ b/lib/page/wifi_settings/cards/usp_wifi_networks_card.dart @@ -54,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, @@ -69,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(), ], ], @@ -93,15 +94,15 @@ class UspWifiNetworksCard extends ConsumerWidget { BuildContext context, WidgetRef ref, _WifiNetworkEntry network, + bool isLoading, ) { - final isLoading = ref.watch(uspMutationLoadingProvider) == 'wifi_network'; - 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), 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/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 { From f2af7bb82449c5ac26ff6f713709cc12e6547a9e Mon Sep 17 00:00:00 2001 From: Hank Yu <52936029+HankYuLinksys@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:36:54 +0800 Subject: [PATCH 51/56] fix(pnp): distinguish router read failure from no-internet (#1098) (#1132) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(pnp): distinguish router read failure from no-internet (#1098) The PnP internet check collapsed two distinct outcomes into NoInternet: a genuine "no internet" (WAN read succeeds, Status != 'Up') and a read failure (USP GET returns empty / missing fields → WanStatus.fetch throws). The two are already separated by control flow — return false vs throw — but the catch block discarded that distinction, so "Try again" could never recover while GETs stayed empty, and the no-internet troubleshooter options (restart modem / enter ISP settings) were shown for a read failure where they are meaningless. Split the two paths: return false → NoInternet (unchanged); throw → new AdminReadFailure phase, carrying the ServiceError code/detail for diagnostics and rendered as a plain error card + retry on the entry view (no redirect to the no-internet hub). Both startPostLoginFlow and _checkInternet now branch on ServiceError, so a read failure at either the SystemInfo or WanStatus step lands in the same phase. AdminReadFailure fully replaces the former AdminError. * fix(pnp): route read failures back to entry from all check callers (#1098) The classification fix landed AdminReadFailure at the notifier, but _checkInternet() has multiple callers and only the entry view rendered the new phase. The no-internet retry and the ISP-save (PPPoE / Static IP / DHCP) flows had no branch for it, so a read failure there would stall silently. Route every read-failure path back to the entry view (RoutePath.pnp). Because /pnp and the no-internet subtree are independent top-level route trees, go() mounts a fresh PnpEntryView whose initState re-runs startPostLoginFlow — an implicit retry that settles on the read-failure card only if it still fails. The ISP-save WRITE failure path is deliberately left on NoInternet + snackbar: _checkInternet does not rethrow, so a trailing check read-failure is handled inside it (AdminReadFailure), and only a genuine save-write failure reaches the saveIspWithProgress catch — the user should stay on the form to fix it. Also delete saveIspSettingsAndCheck (dead code, zero callers; superseded by saveIspWithProgress) and add a regression test for the save-succeeds-but-check- read-fails path. * style(pnp): apply dart format to pnp_notifier_test --- lib/page/instant_setup/models/pnp_state.dart | 22 +++++-- .../instant_setup/providers/pnp_notifier.dart | 44 ++++++------- .../instant_setup/views/pnp_entry_view.dart | 9 ++- .../views/pnp_isp_settings_view.dart | 5 ++ .../views/pnp_no_internet_view.dart | 5 ++ .../instant_setup/views/pnp_pppoe_view.dart | 5 ++ .../views/pnp_static_ip_view.dart | 5 ++ .../providers/pnp_notifier_test.dart | 65 +++++++++++++++++-- 8 files changed, 123 insertions(+), 37 deletions(-) diff --git a/lib/page/instant_setup/models/pnp_state.dart b/lib/page/instant_setup/models/pnp_state.dart index 5b5f7920d..49e9f8d6a 100644 --- a/lib/page/instant_setup/models/pnp_state.dart +++ b/lib/page/instant_setup/models/pnp_state.dart @@ -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}); - @override - List get props => [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 => [code, detail]; } /// No internet detected — route to troubleshooter. diff --git a/lib/page/instant_setup/providers/pnp_notifier.dart b/lib/page/instant_setup/providers/pnp_notifier.dart index e77e9b145..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')); } } @@ -379,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/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/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(); + }); }); } From 0cc56ef0acae46913fbe42d5dbecd7e85fbf4725 Mon Sep 17 00:00:00 2001 From: AustinChangLinksys <79675086+AustinChangLinksys@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:33:18 +0800 Subject: [PATCH 52/56] feat(fonts): offline CJK subsetting + zero-CDN fallback (12.7MB -> 2.14MB) (#1135) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(fonts): bundle CJK/non-Latin subset fonts for offline rendering Ship the interface-charset subset of Noto Sans CJK (SC/TC/HK/JP/KR) plus full Thai/Arabic/Latin-ext and Roboto, declared eager under pubspec `fonts:` so the CanvasKit fallback manager finds them locally and never probes the CDN. CJK drops from 12.7MB to 2.14MB (84%) while every language keeps correct glyphs. - assets/fonts/fallback/: 8 subset/full woff2 + Roboto (engine default fallback) - pubspec: eager `fonts:` declarations (packages/ui_kit_library/* + bare Roboto), ui_kit bumped to v2.28.1 (adds injectable LocaleFallbackFont hook) - lib/localization/fallback_font_resolver.dart: single source of the locale to family mapping; install() injects it into ui_kit at startup - app.dart: add per-locale fallback to ThemeData.textTheme (covers raw Text) - language_tile.dart: per-item Localizations.override so the picker renders every language's native name with the correct family - flutter_bootstrap.js: fontFallbackBaseUrl stays on CDN so online, rare user-typed glyphs outside the subset are still fetched on demand Co-Authored-By: Claude Opus 4.8 * chore(fonts): remove redundant full CJK engine-fallback fonts (~13MB) These full Noto woff2 chunk sets were mirrored locally for CanvasKit's engine-level fallback. They are now superseded by the eager-bundled subset fonts (previous commit): interface text is covered by the subset, and rare user-typed glyphs are filled from the CDN when online. Removing them saves ~13MB of source tree / product weight with no offline regression (verified: all locales still render offline via the bundled subsets). Co-Authored-By: Claude Opus 4.8 * chore(tools): add CJK subset font regeneration tooling Build-time tool that regenerates the bundled CJK subset fonts from the current interface charset. MUST be re-run after any change that adds CJK glyphs (ARB strings, language names, hardcoded literals) — otherwise the subset silently misses them (offline tofu / online CDN fetch). - regenerate.sh: one-command pipeline (download full OTFs, extract charset, subset, deploy to assets/fonts/fallback/) - extract_charset.py: unions ARB values + CJK punctuation blocks + picker native names + hardcoded CJK in Dart source - make_test_page.py: renders per-locale samples for glyph-correctness eyeballing - README documents the "when to re-run" maintenance rule - .venv/full_fonts/out are reproducible intermediates (gitignored) Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .../fonts/fallback/NotoSans-Latin.woff2 | Bin .../fonts/fallback/NotoSansArabic.woff2 | Bin .../fonts/fallback/NotoSansCJKhk.subset.woff2 | Bin 0 -> 448256 bytes .../fonts/fallback/NotoSansCJKjp.subset.woff2 | Bin 0 -> 453288 bytes .../fonts/fallback/NotoSansCJKkr.subset.woff2 | Bin 0 -> 447984 bytes .../fonts/fallback/NotoSansCJKsc.subset.woff2 | Bin 0 -> 448348 bytes .../fonts/fallback/NotoSansCJKtc.subset.woff2 | Bin 0 -> 448016 bytes .../fonts/fallback/NotoSansThai.woff2 | Bin .../fonts/fallback/Roboto.woff2 | Bin lib/app.dart | 25 +++- .../language_tile.dart | 17 ++- lib/localization/fallback_font_resolver.dart | 92 ++++++++++++++ lib/main.dart | 5 + pubspec.yaml | 46 ++++++- tools/font_subset/.gitignore | 4 + tools/font_subset/README.md | 77 ++++++++++++ tools/font_subset/extract_charset.py | 115 ++++++++++++++++++ tools/font_subset/make_test_page.py | 87 +++++++++++++ tools/font_subset/regenerate.sh | 83 +++++++++++++ ...T7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.0.woff2 | Bin 34920 -> 0 bytes ...T7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.1.woff2 | Bin 37880 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.10.woff2 | Bin 10996 -> 0 bytes ...-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.100.woff2 | Bin 33516 -> 0 bytes ...-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.101.woff2 | Bin 33156 -> 0 bytes ...-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.102.woff2 | Bin 33348 -> 0 bytes ...-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.103.woff2 | Bin 37160 -> 0 bytes ...-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.104.woff2 | Bin 36400 -> 0 bytes ...-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.105.woff2 | Bin 36264 -> 0 bytes ...-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.106.woff2 | Bin 38364 -> 0 bytes ...-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.107.woff2 | Bin 38824 -> 0 bytes ...-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.108.woff2 | Bin 38428 -> 0 bytes ...-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.109.woff2 | Bin 38328 -> 0 bytes ...-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.110.woff2 | Bin 42292 -> 0 bytes ...-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.111.woff2 | Bin 39680 -> 0 bytes ...-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.112.woff2 | Bin 38748 -> 0 bytes ...-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.113.woff2 | Bin 37920 -> 0 bytes ...-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.114.woff2 | Bin 45292 -> 0 bytes ...-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.115.woff2 | Bin 39268 -> 0 bytes ...-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.116.woff2 | Bin 37820 -> 0 bytes ...-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.117.woff2 | Bin 34856 -> 0 bytes ...-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.118.woff2 | Bin 32164 -> 0 bytes ...-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.119.woff2 | Bin 18596 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.15.woff2 | Bin 12264 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.16.woff2 | Bin 8356 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.17.woff2 | Bin 2368 -> 0 bytes ...T7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.2.woff2 | Bin 37324 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.25.woff2 | Bin 12736 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.26.woff2 | Bin 35728 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.27.woff2 | Bin 31524 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.28.woff2 | Bin 29396 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.29.woff2 | Bin 29472 -> 0 bytes ...T7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.3.woff2 | Bin 34168 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.30.woff2 | Bin 25608 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.31.woff2 | Bin 27908 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.32.woff2 | Bin 10188 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.33.woff2 | Bin 33432 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.34.woff2 | Bin 31272 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.35.woff2 | Bin 27324 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.36.woff2 | Bin 26724 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.37.woff2 | Bin 26520 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.38.woff2 | Bin 22456 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.39.woff2 | Bin 18436 -> 0 bytes ...T7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.4.woff2 | Bin 34692 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.40.woff2 | Bin 26368 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.41.woff2 | Bin 32740 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.42.woff2 | Bin 31548 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.43.woff2 | Bin 33392 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.44.woff2 | Bin 28196 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.45.woff2 | Bin 20180 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.46.woff2 | Bin 27728 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.47.woff2 | Bin 27256 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.48.woff2 | Bin 18232 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.49.woff2 | Bin 37504 -> 0 bytes ...T7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.5.woff2 | Bin 34460 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.50.woff2 | Bin 35888 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.51.woff2 | Bin 28108 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.52.woff2 | Bin 27280 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.53.woff2 | Bin 25200 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.54.woff2 | Bin 27516 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.55.woff2 | Bin 27340 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.56.woff2 | Bin 28096 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.57.woff2 | Bin 27204 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.58.woff2 | Bin 32148 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.59.woff2 | Bin 32404 -> 0 bytes ...T7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.6.woff2 | Bin 34096 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.60.woff2 | Bin 33472 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.61.woff2 | Bin 24788 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.62.woff2 | Bin 21028 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.63.woff2 | Bin 32484 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.64.woff2 | Bin 31572 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.65.woff2 | Bin 26376 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.66.woff2 | Bin 21444 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.67.woff2 | Bin 23580 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.68.woff2 | Bin 31856 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.69.woff2 | Bin 21060 -> 0 bytes ...T7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.7.woff2 | Bin 33520 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.70.woff2 | Bin 28388 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.71.woff2 | Bin 24128 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.72.woff2 | Bin 23544 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.73.woff2 | Bin 25068 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.74.woff2 | Bin 20672 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.75.woff2 | Bin 33712 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.76.woff2 | Bin 24460 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.77.woff2 | Bin 24520 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.78.woff2 | Bin 26440 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.79.woff2 | Bin 23520 -> 0 bytes ...T7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.8.woff2 | Bin 36768 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.80.woff2 | Bin 17772 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.81.woff2 | Bin 21752 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.82.woff2 | Bin 25836 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.83.woff2 | Bin 19624 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.84.woff2 | Bin 22048 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.85.woff2 | Bin 35572 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.86.woff2 | Bin 34120 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.87.woff2 | Bin 32396 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.88.woff2 | Bin 24420 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.89.woff2 | Bin 3880 -> 0 bytes ...T7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.9.woff2 | Bin 28804 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.90.woff2 | Bin 7724 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.91.woff2 | Bin 6472 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.92.woff2 | Bin 6388 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.93.woff2 | Bin 4776 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.98.woff2 | Bin 1604 -> 0 bytes ...7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.99.woff2 | Bin 5060 -> 0 bytes ...Ej756wwr4v0qHnANADNsISRDl2PRkiiWsg.0.woff2 | Bin 44388 -> 0 bytes ...Ej756wwr4v0qHnANADNsISRDl2PRkiiWsg.1.woff2 | Bin 37756 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.10.woff2 | Bin 41948 -> 0 bytes ...756wwr4v0qHnANADNsISRDl2PRkiiWsg.100.woff2 | Bin 12644 -> 0 bytes ...756wwr4v0qHnANADNsISRDl2PRkiiWsg.101.woff2 | Bin 10212 -> 0 bytes ...756wwr4v0qHnANADNsISRDl2PRkiiWsg.102.woff2 | Bin 11568 -> 0 bytes ...756wwr4v0qHnANADNsISRDl2PRkiiWsg.103.woff2 | Bin 10404 -> 0 bytes ...756wwr4v0qHnANADNsISRDl2PRkiiWsg.104.woff2 | Bin 11068 -> 0 bytes ...756wwr4v0qHnANADNsISRDl2PRkiiWsg.105.woff2 | Bin 10560 -> 0 bytes ...756wwr4v0qHnANADNsISRDl2PRkiiWsg.106.woff2 | Bin 13096 -> 0 bytes ...756wwr4v0qHnANADNsISRDl2PRkiiWsg.107.woff2 | Bin 10328 -> 0 bytes ...756wwr4v0qHnANADNsISRDl2PRkiiWsg.108.woff2 | Bin 11912 -> 0 bytes ...756wwr4v0qHnANADNsISRDl2PRkiiWsg.109.woff2 | Bin 9256 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.11.woff2 | Bin 40608 -> 0 bytes ...756wwr4v0qHnANADNsISRDl2PRkiiWsg.110.woff2 | Bin 9956 -> 0 bytes ...756wwr4v0qHnANADNsISRDl2PRkiiWsg.111.woff2 | Bin 10644 -> 0 bytes ...756wwr4v0qHnANADNsISRDl2PRkiiWsg.112.woff2 | Bin 9748 -> 0 bytes ...756wwr4v0qHnANADNsISRDl2PRkiiWsg.113.woff2 | Bin 9216 -> 0 bytes ...756wwr4v0qHnANADNsISRDl2PRkiiWsg.114.woff2 | Bin 9288 -> 0 bytes ...756wwr4v0qHnANADNsISRDl2PRkiiWsg.115.woff2 | Bin 10924 -> 0 bytes ...756wwr4v0qHnANADNsISRDl2PRkiiWsg.116.woff2 | Bin 9872 -> 0 bytes ...756wwr4v0qHnANADNsISRDl2PRkiiWsg.117.woff2 | Bin 7524 -> 0 bytes ...756wwr4v0qHnANADNsISRDl2PRkiiWsg.118.woff2 | Bin 9944 -> 0 bytes ...756wwr4v0qHnANADNsISRDl2PRkiiWsg.119.woff2 | Bin 42736 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.12.woff2 | Bin 37340 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.13.woff2 | Bin 40400 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.14.woff2 | Bin 39904 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.15.woff2 | Bin 44016 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.16.woff2 | Bin 38232 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.17.woff2 | Bin 45172 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.18.woff2 | Bin 42836 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.19.woff2 | Bin 47684 -> 0 bytes ...Ej756wwr4v0qHnANADNsISRDl2PRkiiWsg.2.woff2 | Bin 17048 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.20.woff2 | Bin 34400 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.21.woff2 | Bin 39720 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.22.woff2 | Bin 42164 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.23.woff2 | Bin 42256 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.24.woff2 | Bin 45896 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.25.woff2 | Bin 43360 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.26.woff2 | Bin 37084 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.27.woff2 | Bin 35152 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.28.woff2 | Bin 38524 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.29.woff2 | Bin 42568 -> 0 bytes ...Ej756wwr4v0qHnANADNsISRDl2PRkiiWsg.3.woff2 | Bin 23472 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.30.woff2 | Bin 43824 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.31.woff2 | Bin 47336 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.32.woff2 | Bin 40488 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.33.woff2 | Bin 35512 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.34.woff2 | Bin 46344 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.35.woff2 | Bin 38468 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.36.woff2 | Bin 34856 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.37.woff2 | Bin 40232 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.38.woff2 | Bin 37320 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.39.woff2 | Bin 39832 -> 0 bytes ...Ej756wwr4v0qHnANADNsISRDl2PRkiiWsg.4.woff2 | Bin 40852 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.40.woff2 | Bin 34636 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.41.woff2 | Bin 41868 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.42.woff2 | Bin 34896 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.43.woff2 | Bin 38540 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.44.woff2 | Bin 39696 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.45.woff2 | Bin 36920 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.46.woff2 | Bin 31984 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.47.woff2 | Bin 37336 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.48.woff2 | Bin 39100 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.49.woff2 | Bin 32788 -> 0 bytes ...Ej756wwr4v0qHnANADNsISRDl2PRkiiWsg.5.woff2 | Bin 47964 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.50.woff2 | Bin 32488 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.51.woff2 | Bin 30596 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.52.woff2 | Bin 38036 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.53.woff2 | Bin 20172 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.54.woff2 | Bin 16896 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.55.woff2 | Bin 23104 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.56.woff2 | Bin 22728 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.57.woff2 | Bin 6568 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.58.woff2 | Bin 13668 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.59.woff2 | Bin 10332 -> 0 bytes ...Ej756wwr4v0qHnANADNsISRDl2PRkiiWsg.6.woff2 | Bin 49056 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.60.woff2 | Bin 13912 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.61.woff2 | Bin 12168 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.62.woff2 | Bin 12040 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.63.woff2 | Bin 11892 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.64.woff2 | Bin 14948 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.65.woff2 | Bin 12284 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.66.woff2 | Bin 11676 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.67.woff2 | Bin 11080 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.68.woff2 | Bin 12896 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.69.woff2 | Bin 11564 -> 0 bytes ...Ej756wwr4v0qHnANADNsISRDl2PRkiiWsg.7.woff2 | Bin 46660 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.70.woff2 | Bin 12040 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.71.woff2 | Bin 12344 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.72.woff2 | Bin 12628 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.73.woff2 | Bin 12052 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.74.woff2 | Bin 11512 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.75.woff2 | Bin 12112 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.76.woff2 | Bin 11508 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.77.woff2 | Bin 11024 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.78.woff2 | Bin 12880 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.79.woff2 | Bin 12300 -> 0 bytes ...Ej756wwr4v0qHnANADNsISRDl2PRkiiWsg.8.woff2 | Bin 43120 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.80.woff2 | Bin 11416 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.81.woff2 | Bin 11076 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.82.woff2 | Bin 12008 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.83.woff2 | Bin 11856 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.84.woff2 | Bin 11752 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.85.woff2 | Bin 10764 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.86.woff2 | Bin 10760 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.87.woff2 | Bin 12184 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.88.woff2 | Bin 11132 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.89.woff2 | Bin 11224 -> 0 bytes ...Ej756wwr4v0qHnANADNsISRDl2PRkiiWsg.9.woff2 | Bin 42168 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.90.woff2 | Bin 12220 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.91.woff2 | Bin 12164 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.92.woff2 | Bin 11600 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.93.woff2 | Bin 10948 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.94.woff2 | Bin 12292 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.95.woff2 | Bin 11352 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.96.woff2 | Bin 12176 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.97.woff2 | Bin 11164 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.98.woff2 | Bin 11624 -> 0 bytes ...j756wwr4v0qHnANADNsISRDl2PRkiiWsg.99.woff2 | Bin 11892 -> 0 bytes ...zuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.0.woff2 | Bin 18120 -> 0 bytes ...zuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.1.woff2 | Bin 29692 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.10.woff2 | Bin 10936 -> 0 bytes ...oyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.100.woff2 | Bin 14196 -> 0 bytes ...oyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.101.woff2 | Bin 14224 -> 0 bytes ...oyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.102.woff2 | Bin 14316 -> 0 bytes ...oyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.103.woff2 | Bin 13416 -> 0 bytes ...oyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.104.woff2 | Bin 15680 -> 0 bytes ...oyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.105.woff2 | Bin 13828 -> 0 bytes ...oyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.106.woff2 | Bin 13208 -> 0 bytes ...oyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.107.woff2 | Bin 10476 -> 0 bytes ...oyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.108.woff2 | Bin 10760 -> 0 bytes ...oyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.109.woff2 | Bin 9784 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.11.woff2 | Bin 10568 -> 0 bytes ...oyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.110.woff2 | Bin 9548 -> 0 bytes ...oyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.111.woff2 | Bin 9588 -> 0 bytes ...oyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.112.woff2 | Bin 9048 -> 0 bytes ...oyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.113.woff2 | Bin 8968 -> 0 bytes ...oyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.114.woff2 | Bin 9032 -> 0 bytes ...oyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.115.woff2 | Bin 8896 -> 0 bytes ...oyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.116.woff2 | Bin 8796 -> 0 bytes ...oyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.117.woff2 | Bin 7884 -> 0 bytes ...oyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.118.woff2 | Bin 7916 -> 0 bytes ...oyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.119.woff2 | Bin 9016 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.12.woff2 | Bin 11880 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.13.woff2 | Bin 11712 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.14.woff2 | Bin 11412 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.15.woff2 | Bin 11616 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.16.woff2 | Bin 10828 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.17.woff2 | Bin 11896 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.18.woff2 | Bin 10900 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.19.woff2 | Bin 12632 -> 0 bytes ...zuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.2.woff2 | Bin 18916 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.20.woff2 | Bin 13236 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.21.woff2 | Bin 11536 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.22.woff2 | Bin 12184 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.23.woff2 | Bin 12224 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.24.woff2 | Bin 12940 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.25.woff2 | Bin 10192 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.26.woff2 | Bin 12944 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.27.woff2 | Bin 12416 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.28.woff2 | Bin 11088 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.29.woff2 | Bin 11968 -> 0 bytes ...zuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.3.woff2 | Bin 12752 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.30.woff2 | Bin 12256 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.31.woff2 | Bin 11968 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.32.woff2 | Bin 13680 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.33.woff2 | Bin 12796 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.34.woff2 | Bin 11184 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.35.woff2 | Bin 11212 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.36.woff2 | Bin 11576 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.37.woff2 | Bin 11536 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.38.woff2 | Bin 9424 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.39.woff2 | Bin 11408 -> 0 bytes ...zuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.4.woff2 | Bin 12052 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.40.woff2 | Bin 11236 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.41.woff2 | Bin 9940 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.42.woff2 | Bin 10784 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.43.woff2 | Bin 11176 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.44.woff2 | Bin 9956 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.45.woff2 | Bin 10500 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.46.woff2 | Bin 11192 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.47.woff2 | Bin 11580 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.48.woff2 | Bin 11444 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.49.woff2 | Bin 11628 -> 0 bytes ...zuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.5.woff2 | Bin 10692 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.50.woff2 | Bin 11000 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.51.woff2 | Bin 10108 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.52.woff2 | Bin 11552 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.53.woff2 | Bin 11180 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.54.woff2 | Bin 11088 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.55.woff2 | Bin 10896 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.56.woff2 | Bin 11200 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.57.woff2 | Bin 11268 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.58.woff2 | Bin 11656 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.59.woff2 | Bin 13056 -> 0 bytes ...zuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.6.woff2 | Bin 10892 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.60.woff2 | Bin 12592 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.61.woff2 | Bin 12416 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.62.woff2 | Bin 11380 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.63.woff2 | Bin 11000 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.64.woff2 | Bin 9744 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.65.woff2 | Bin 34328 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.66.woff2 | Bin 29968 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.67.woff2 | Bin 25820 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.68.woff2 | Bin 27428 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.69.woff2 | Bin 25836 -> 0 bytes ...zuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.7.woff2 | Bin 10996 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.70.woff2 | Bin 25812 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.71.woff2 | Bin 30348 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.72.woff2 | Bin 24564 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.73.woff2 | Bin 26872 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.74.woff2 | Bin 28668 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.75.woff2 | Bin 30128 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.76.woff2 | Bin 25812 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.77.woff2 | Bin 28120 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.78.woff2 | Bin 25820 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.79.woff2 | Bin 32168 -> 0 bytes ...zuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.8.woff2 | Bin 10192 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.80.woff2 | Bin 26948 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.81.woff2 | Bin 25484 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.82.woff2 | Bin 26788 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.83.woff2 | Bin 22084 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.84.woff2 | Bin 28852 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.85.woff2 | Bin 27812 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.86.woff2 | Bin 27384 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.87.woff2 | Bin 23892 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.88.woff2 | Bin 24624 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.89.woff2 | Bin 24416 -> 0 bytes ...zuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.9.woff2 | Bin 11848 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.90.woff2 | Bin 22436 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.91.woff2 | Bin 23892 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.92.woff2 | Bin 22632 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.93.woff2 | Bin 15480 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.94.woff2 | Bin 10144 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.95.woff2 | Bin 28368 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.96.woff2 | Bin 12228 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.97.woff2 | Bin 7488 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.98.woff2 | Bin 6532 -> 0 bytes ...uoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.99.woff2 | Bin 9044 -> 0 bytes ...sFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.100.woff2 | Bin 34364 -> 0 bytes ...sFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.101.woff2 | Bin 31032 -> 0 bytes ...sFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.102.woff2 | Bin 32136 -> 0 bytes ...sFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.103.woff2 | Bin 33960 -> 0 bytes ...sFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.104.woff2 | Bin 32596 -> 0 bytes ...sFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.105.woff2 | Bin 32844 -> 0 bytes ...sFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.106.woff2 | Bin 33300 -> 0 bytes ...sFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.107.woff2 | Bin 32464 -> 0 bytes ...sFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.108.woff2 | Bin 34160 -> 0 bytes ...sFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.109.woff2 | Bin 32068 -> 0 bytes ...sFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.110.woff2 | Bin 32732 -> 0 bytes ...sFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.111.woff2 | Bin 33256 -> 0 bytes ...sFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.112.woff2 | Bin 30496 -> 0 bytes ...sFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.113.woff2 | Bin 31044 -> 0 bytes ...sFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.114.woff2 | Bin 30780 -> 0 bytes ...sFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.115.woff2 | Bin 30056 -> 0 bytes ...sFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.116.woff2 | Bin 28216 -> 0 bytes ...sFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.117.woff2 | Bin 27952 -> 0 bytes ...sFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.118.woff2 | Bin 24780 -> 0 bytes ...sFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.119.woff2 | Bin 41388 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.21.woff2 | Bin 6212 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.22.woff2 | Bin 34592 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.23.woff2 | Bin 30560 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.24.woff2 | Bin 33364 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.25.woff2 | Bin 31224 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.26.woff2 | Bin 27280 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.27.woff2 | Bin 25456 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.28.woff2 | Bin 26488 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.29.woff2 | Bin 28652 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.30.woff2 | Bin 29192 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.31.woff2 | Bin 25476 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.32.woff2 | Bin 27632 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.33.woff2 | Bin 25496 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.34.woff2 | Bin 25272 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.35.woff2 | Bin 26736 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.36.woff2 | Bin 22724 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.37.woff2 | Bin 28208 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.38.woff2 | Bin 28992 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.39.woff2 | Bin 27708 -> 0 bytes ...gFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.4.woff2 | Bin 2316 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.40.woff2 | Bin 24764 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.41.woff2 | Bin 19448 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.42.woff2 | Bin 24460 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.43.woff2 | Bin 25012 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.44.woff2 | Bin 28792 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.45.woff2 | Bin 36892 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.46.woff2 | Bin 32216 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.47.woff2 | Bin 29804 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.48.woff2 | Bin 26940 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.49.woff2 | Bin 28136 -> 0 bytes ...gFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.5.woff2 | Bin 11004 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.50.woff2 | Bin 24832 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.51.woff2 | Bin 22516 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.52.woff2 | Bin 27448 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.53.woff2 | Bin 23164 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.54.woff2 | Bin 27796 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.55.woff2 | Bin 25788 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.56.woff2 | Bin 28588 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.57.woff2 | Bin 29652 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.58.woff2 | Bin 30388 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.59.woff2 | Bin 25144 -> 0 bytes ...gFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.6.woff2 | Bin 6532 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.60.woff2 | Bin 22276 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.61.woff2 | Bin 24236 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.62.woff2 | Bin 31104 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.63.woff2 | Bin 25960 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.64.woff2 | Bin 21944 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.65.woff2 | Bin 22424 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.66.woff2 | Bin 27956 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.67.woff2 | Bin 28080 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.68.woff2 | Bin 22224 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.69.woff2 | Bin 29632 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.70.woff2 | Bin 22516 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.71.woff2 | Bin 25632 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.72.woff2 | Bin 25044 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.73.woff2 | Bin 20884 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.74.woff2 | Bin 30068 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.75.woff2 | Bin 23684 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.76.woff2 | Bin 25804 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.77.woff2 | Bin 19728 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.78.woff2 | Bin 26172 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.79.woff2 | Bin 21980 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.80.woff2 | Bin 17308 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.81.woff2 | Bin 25164 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.82.woff2 | Bin 24412 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.83.woff2 | Bin 23088 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.84.woff2 | Bin 17584 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.85.woff2 | Bin 14624 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.86.woff2 | Bin 18056 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.87.woff2 | Bin 5256 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.88.woff2 | Bin 6292 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.89.woff2 | Bin 6128 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.90.woff2 | Bin 5300 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.91.woff2 | Bin 4800 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.97.woff2 | Bin 1488 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.98.woff2 | Bin 2116 -> 0 bytes ...FsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.99.woff2 | Bin 5268 -> 0 bytes ...mixcA63oeAL7Iqp5IZJF9bmaG9_FrY9HbczS.woff2 | Bin 5324 -> 0 bytes ...brbDN6gxP34F9jRRCe4W3gfQ8gb_VFRkzrbQ.woff2 | Bin 69116 -> 0 bytes ...FVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.0.woff2 | Bin 5120 -> 0 bytes ...oizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.100.woff2 | Bin 33644 -> 0 bytes ...oizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.101.woff2 | Bin 33300 -> 0 bytes ...oizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.102.woff2 | Bin 33564 -> 0 bytes ...oizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.103.woff2 | Bin 37308 -> 0 bytes ...oizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.104.woff2 | Bin 36604 -> 0 bytes ...oizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.105.woff2 | Bin 36236 -> 0 bytes ...oizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.106.woff2 | Bin 38496 -> 0 bytes ...oizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.107.woff2 | Bin 38872 -> 0 bytes ...oizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.108.woff2 | Bin 38516 -> 0 bytes ...oizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.109.woff2 | Bin 38284 -> 0 bytes ...oizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.110.woff2 | Bin 42040 -> 0 bytes ...oizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.111.woff2 | Bin 39736 -> 0 bytes ...oizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.112.woff2 | Bin 38936 -> 0 bytes ...oizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.113.woff2 | Bin 38024 -> 0 bytes ...oizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.114.woff2 | Bin 45776 -> 0 bytes ...oizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.115.woff2 | Bin 39392 -> 0 bytes ...oizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.116.woff2 | Bin 38020 -> 0 bytes ...oizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.117.woff2 | Bin 34840 -> 0 bytes ...oizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.118.woff2 | Bin 32172 -> 0 bytes ...oizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.119.woff2 | Bin 18704 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.19.woff2 | Bin 15664 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.20.woff2 | Bin 28256 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.21.woff2 | Bin 23748 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.22.woff2 | Bin 23148 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.23.woff2 | Bin 21708 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.24.woff2 | Bin 20260 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.25.woff2 | Bin 23516 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.26.woff2 | Bin 12496 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.27.woff2 | Bin 14460 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.28.woff2 | Bin 27808 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.29.woff2 | Bin 23716 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.30.woff2 | Bin 23384 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.31.woff2 | Bin 18976 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.32.woff2 | Bin 23744 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.33.woff2 | Bin 16584 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.34.woff2 | Bin 11544 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.35.woff2 | Bin 22456 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.36.woff2 | Bin 26108 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.37.woff2 | Bin 27200 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.38.woff2 | Bin 23516 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.39.woff2 | Bin 26824 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.40.woff2 | Bin 21144 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.41.woff2 | Bin 15872 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.42.woff2 | Bin 23552 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.43.woff2 | Bin 21512 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.44.woff2 | Bin 11524 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.45.woff2 | Bin 31284 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.46.woff2 | Bin 29932 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.47.woff2 | Bin 22404 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.48.woff2 | Bin 22956 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.49.woff2 | Bin 21900 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.50.woff2 | Bin 19456 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.51.woff2 | Bin 23396 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.52.woff2 | Bin 22944 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.53.woff2 | Bin 21668 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.54.woff2 | Bin 22892 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.55.woff2 | Bin 25692 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.56.woff2 | Bin 26672 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.57.woff2 | Bin 27232 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.58.woff2 | Bin 22712 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.59.woff2 | Bin 17560 -> 0 bytes ...FVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.6.woff2 | Bin 8508 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.60.woff2 | Bin 20028 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.61.woff2 | Bin 28488 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.62.woff2 | Bin 23972 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.63.woff2 | Bin 20368 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.64.woff2 | Bin 17760 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.65.woff2 | Bin 18888 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.66.woff2 | Bin 26096 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.67.woff2 | Bin 18572 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.68.woff2 | Bin 18872 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.69.woff2 | Bin 24100 -> 0 bytes ...FVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.7.woff2 | Bin 8012 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.70.woff2 | Bin 16656 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.71.woff2 | Bin 20044 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.72.woff2 | Bin 20312 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.73.woff2 | Bin 14700 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.74.woff2 | Bin 27560 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.75.woff2 | Bin 19844 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.76.woff2 | Bin 21136 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.77.woff2 | Bin 18296 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.78.woff2 | Bin 22096 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.79.woff2 | Bin 15052 -> 0 bytes ...FVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.8.woff2 | Bin 5416 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.80.woff2 | Bin 14600 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.81.woff2 | Bin 16732 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.82.woff2 | Bin 22964 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.83.woff2 | Bin 16004 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.84.woff2 | Bin 10340 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.85.woff2 | Bin 19848 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.86.woff2 | Bin 7316 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.87.woff2 | Bin 4040 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.88.woff2 | Bin 6336 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.89.woff2 | Bin 6056 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.90.woff2 | Bin 5360 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.91.woff2 | Bin 5020 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.92.woff2 | Bin 2148 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.97.woff2 | Bin 1256 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.98.woff2 | Bin 1956 -> 0 bytes ...VoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.99.woff2 | Bin 4780 -> 0 bytes web/flutter_bootstrap.js | 8 +- 565 files changed, 550 insertions(+), 9 deletions(-) rename web/assets/notosans/v37/o-0mIpQlx3QUlC5A4PNB6Ryti20_6n1iPHjcz6L1SoM-jCpoiyD9A99Y41P6zHtY.woff2 => assets/fonts/fallback/NotoSans-Latin.woff2 (100%) rename web/assets/notosansarabic/v28/nwpxtLGrOAZMl5nJ_wfgRg3DrWFZWsnVBJ_sS6tlqHHFlhQ5l3sQWIHPqzCfyGyvvnCBFQLaig.woff2 => assets/fonts/fallback/NotoSansArabic.woff2 (100%) create mode 100644 assets/fonts/fallback/NotoSansCJKhk.subset.woff2 create mode 100644 assets/fonts/fallback/NotoSansCJKjp.subset.woff2 create mode 100644 assets/fonts/fallback/NotoSansCJKkr.subset.woff2 create mode 100644 assets/fonts/fallback/NotoSansCJKsc.subset.woff2 create mode 100644 assets/fonts/fallback/NotoSansCJKtc.subset.woff2 rename web/assets/notosansthai/v25/iJWnBXeUZi_OHPqn4wq6hQ2_hbJ1xyN9wd43SofNWcd1MKVQt_So_9CdU5RtpzR-QRvzzXg.woff2 => assets/fonts/fallback/NotoSansThai.woff2 (100%) rename web/assets/roboto/v32/KFOmCnqEu92Fr1Me4GZLCzYlKw.woff2 => assets/fonts/fallback/Roboto.woff2 (100%) create mode 100644 lib/localization/fallback_font_resolver.dart create mode 100644 tools/font_subset/.gitignore create mode 100644 tools/font_subset/README.md create mode 100644 tools/font_subset/extract_charset.py create mode 100644 tools/font_subset/make_test_page.py create mode 100755 tools/font_subset/regenerate.sh delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.0.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.1.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.10.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.100.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.101.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.102.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.103.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.104.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.105.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.106.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.107.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.108.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.109.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.110.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.111.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.112.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.113.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.114.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.115.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.116.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.117.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.118.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.119.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.15.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.16.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.17.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.2.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.25.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.26.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.27.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.28.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.29.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.3.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.30.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.31.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.32.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.33.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.34.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.35.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.36.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.37.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.38.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.39.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.4.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.40.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.41.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.42.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.43.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.44.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.45.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.46.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.47.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.48.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.49.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.5.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.50.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.51.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.52.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.53.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.54.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.55.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.56.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.57.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.58.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.59.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.6.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.60.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.61.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.62.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.63.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.64.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.65.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.66.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.67.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.68.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.69.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.7.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.70.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.71.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.72.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.73.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.74.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.75.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.76.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.77.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.78.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.79.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.8.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.80.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.81.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.82.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.83.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.84.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.85.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.86.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.87.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.88.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.89.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.9.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.90.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.91.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.92.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.93.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.98.woff2 delete mode 100644 web/assets/notosanshk/v32/nKKF-GM_FYFRJvXzVXaAPe97P1KHynJFP716qHB--oD7kYrUzT7-NvA3pTohjc3XVtNXX8A7gG1LO2KAPAw.99.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.0.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.1.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.10.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.100.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.101.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.102.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.103.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.104.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.105.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.106.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.107.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.108.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.109.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.11.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.110.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.111.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.112.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.113.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.114.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.115.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.116.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.117.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.118.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.119.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.12.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.13.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.14.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.15.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.16.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.17.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.18.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.19.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.2.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.20.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.21.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.22.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.23.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.24.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.25.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.26.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.27.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.28.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.29.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.3.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.30.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.31.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.32.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.33.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.34.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.35.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.36.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.37.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.38.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.39.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.4.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.40.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.41.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.42.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.43.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.44.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.45.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.46.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.47.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.48.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.49.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.5.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.50.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.51.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.52.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.53.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.54.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.55.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.56.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.57.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.58.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.59.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.6.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.60.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.61.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.62.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.63.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.64.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.65.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.66.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.67.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.68.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.69.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.7.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.70.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.71.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.72.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.73.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.74.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.75.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.76.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.77.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.78.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.79.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.8.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.80.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.81.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.82.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.83.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.84.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.85.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.86.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.87.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.88.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.89.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.9.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.90.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.91.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.92.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.93.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.94.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.95.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.96.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.97.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.98.woff2 delete mode 100644 web/assets/notosansjp/v53/-F6jfjtqLzI2JPCgQBnw7HFyzSD-AsregP8VFBEj756wwr4v0qHnANADNsISRDl2PRkiiWsg.99.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.0.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.1.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.10.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.100.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.101.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.102.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.103.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.104.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.105.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.106.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.107.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.108.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.109.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.11.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.110.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.111.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.112.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.113.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.114.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.115.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.116.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.117.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.118.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.119.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.12.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.13.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.14.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.15.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.16.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.17.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.18.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.19.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.2.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.20.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.21.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.22.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.23.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.24.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.25.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.26.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.27.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.28.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.29.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.3.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.30.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.31.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.32.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.33.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.34.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.35.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.36.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.37.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.38.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.39.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.4.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.40.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.41.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.42.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.43.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.44.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.45.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.46.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.47.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.48.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.49.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.5.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.50.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.51.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.52.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.53.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.54.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.55.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.56.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.57.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.58.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.59.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.6.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.60.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.61.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.62.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.63.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.64.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.65.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.66.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.67.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.68.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.69.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.7.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.70.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.71.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.72.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.73.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.74.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.75.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.76.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.77.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.78.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.79.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.8.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.80.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.81.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.82.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.83.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.84.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.85.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.86.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.87.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.88.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.89.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.9.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.90.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.91.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.92.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.93.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.94.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.95.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.96.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.97.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.98.woff2 delete mode 100644 web/assets/notosanskr/v36/PbyxFmXiEBPT4ITbgNA5Cgms3VYcOA-vvnIzzuoyeLGC5nwuDo-KBTUm6CryotyJROlrnQ.99.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.100.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.101.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.102.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.103.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.104.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.105.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.106.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.107.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.108.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.109.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.110.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.111.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.112.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.113.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.114.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.115.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.116.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.117.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.118.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.119.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.21.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.22.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.23.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.24.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.25.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.26.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.27.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.28.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.29.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.30.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.31.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.32.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.33.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.34.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.35.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.36.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.37.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.38.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.39.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.4.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.40.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.41.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.42.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.43.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.44.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.45.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.46.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.47.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.48.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.49.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.5.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.50.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.51.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.52.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.53.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.54.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.55.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.56.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.57.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.58.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.59.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.6.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.60.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.61.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.62.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.63.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.64.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.65.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.66.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.67.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.68.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.69.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.70.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.71.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.72.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.73.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.74.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.75.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.76.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.77.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.78.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.79.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.80.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.81.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.82.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.83.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.84.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.85.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.86.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.87.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.88.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.89.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.90.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.91.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.97.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.98.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FnYkldv7JjxkkgFsFSSOPMOkySAZ73y9ViAt3acb8NexQ2w.99.woff2 delete mode 100644 web/assets/notosanssc/v37/k3kCo84MPvpLmixcA63oeAL7Iqp5IZJF9bmaG9_FrY9HbczS.woff2 delete mode 100644 web/assets/notosanssymbols/v43/rP2up3q65FkAtHfwd-eIS2brbDN6gxP34F9jRRCe4W3gfQ8gb_VFRkzrbQ.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.0.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.100.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.101.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.102.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.103.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.104.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.105.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.106.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.107.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.108.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.109.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.110.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.111.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.112.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.113.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.114.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.115.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.116.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.117.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.118.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.119.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.19.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.20.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.21.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.22.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.23.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.24.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.25.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.26.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.27.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.28.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.29.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.30.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.31.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.32.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.33.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.34.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.35.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.36.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.37.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.38.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.39.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.40.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.41.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.42.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.43.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.44.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.45.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.46.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.47.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.48.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.49.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.50.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.51.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.52.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.53.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.54.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.55.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.56.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.57.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.58.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.59.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.6.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.60.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.61.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.62.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.63.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.64.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.65.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.66.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.67.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.68.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.69.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.7.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.70.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.71.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.72.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.73.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.74.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.75.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.76.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.77.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.78.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.79.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.8.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.80.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.81.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.82.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.83.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.84.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.85.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.86.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.87.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.88.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.89.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.90.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.91.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.92.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.97.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.98.woff2 delete mode 100644 web/assets/notosanstc/v36/-nFuOG829Oofr2wohFbTp9ifNAn722rq0MXz76Cy_C8mrWSt1KeqzFVoizG-KdWhyhvKuGOf8EUcrq3YKp7nxxk.99.woff2 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 0000000000000000000000000000000000000000..f50233fa8130f310cac6ba359652468b347bf166 GIT binary patch literal 448256 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 z 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