@@ -2,6 +2,7 @@ import 'dart:async';
22import 'dart:convert' ;
33
44import 'package:fl_clash/byedpi/host_list.dart' ;
5+ import 'package:fl_clash/byedpi/test_store.dart' ;
56import 'package:fl_clash/controller.dart' ;
67import 'package:fl_clash/plugins/service.dart' ;
78import 'package:fl_clash/providers/byedpi.dart' ;
@@ -26,6 +27,7 @@ class StrategyTestResult {
2627 final int success;
2728 final int totalRequests;
2829 final List <SiteOutcome > sites;
30+ final int ? testedAt; // epoch ms
2931
3032 const StrategyTestResult ({
3133 required this .id,
@@ -34,9 +36,18 @@ class StrategyTestResult {
3436 required this .success,
3537 required this .totalRequests,
3638 required this .sites,
39+ this .testedAt,
3740 });
41+
42+ List <String > get failedHosts => [
43+ for (final s in sites)
44+ if (s.ok == 0 ) s.site,
45+ ];
3846}
3947
48+ // (byedpiCount, vpnCount) reported back to the UI on apply().
49+ typedef ApplySplit = ({int byedpi, int vpn});
50+
4051class StrategyTestState {
4152 final TestPhase phase;
4253 final int completed;
@@ -72,8 +83,9 @@ class StrategyTestState {
7283
7384// Drives the in-app strategy auto-test (bydpi flavor). The native side runs
7485// byedpi standalone (no VPN tun) per strategy and streams progress; we snapshot
75- // and pause the VPN for the run, then restore it. Apply selects the winning id
76- // through the existing preset model.
86+ // and pause the VPN for the run, then restore it. Results persist to a cache so
87+ // the dashboard renders without re-testing. Apply selects the winning id AND
88+ // rewrites the byedpi exclude list (hosts that strategy failed → routed via VPN).
7789@riverpod
7890class StrategyTestController extends _$StrategyTestController {
7991 Service get _svc => Service ();
@@ -83,6 +95,40 @@ class StrategyTestController extends _$StrategyTestController {
8395 @override
8496 StrategyTestState build () => const StrategyTestState ();
8597
98+ // Render last results from the on-disk cache (no re-test).
99+ Future <void > loadCached () async {
100+ if (state.phase == TestPhase .running || state.results.isNotEmpty) return ;
101+ final cache = await readTestResults ();
102+ if (cache.isEmpty) return ;
103+ final results = < StrategyTestResult > [];
104+ cache.forEach ((id, raw) {
105+ if (raw is ! Map ) return ;
106+ results.add (_resultFromCache (id, Map <String , dynamic >.from (raw)));
107+ });
108+ results.sort ((a, b) => b.percent.compareTo (a.percent));
109+ state = StrategyTestState (phase: TestPhase .done, results: results);
110+ }
111+
112+ StrategyTestResult _resultFromCache (String id, Map <String , dynamic > raw) {
113+ final sites = [
114+ for (final s in (raw['sites' ] as List ? ?? []))
115+ SiteOutcome (
116+ (s as Map )['site' ].toString (),
117+ (s['ok' ] as num ).toInt (),
118+ (s['total' ] as num ).toInt (),
119+ ),
120+ ];
121+ return StrategyTestResult (
122+ id: id,
123+ label: (raw['label' ] ?? id).toString (),
124+ percent: (raw['percent' ] as num ? )? .toInt () ?? 0 ,
125+ success: sites.fold (0 , (a, s) => a + s.ok),
126+ totalRequests: sites.fold (0 , (a, s) => a + s.total),
127+ sites: sites,
128+ testedAt: (raw['timestamp' ] as num ? )? .toInt (),
129+ );
130+ }
131+
86132 Future <void > run ({
87133 int requests = 1 ,
88134 int timeout = 5 ,
@@ -176,17 +222,57 @@ class StrategyTestController extends _$StrategyTestController {
176222 await appController.updateStatus (true );
177223 _wasVpnOn = false ;
178224 }
179- final sorted = [...state.results]
180- ..sort ((a, b) => b.percent.compareTo (a.percent));
225+ final now = DateTime .now ().millisecondsSinceEpoch;
226+ final stamped = [
227+ for (final r in state.results)
228+ StrategyTestResult (
229+ id: r.id,
230+ label: r.label,
231+ percent: r.percent,
232+ success: r.success,
233+ totalRequests: r.totalRequests,
234+ sites: r.sites,
235+ testedAt: now,
236+ ),
237+ ]..sort ((a, b) => b.percent.compareTo (a.percent));
238+ await _persist (stamped);
181239 state = StrategyTestState (
182240 phase: TestPhase .done,
183241 completed: state.completed,
184242 total: state.total,
185- results: sorted ,
243+ results: stamped ,
186244 error: error,
187245 );
188246 }
189247
190- Future <void > apply (String id) =>
191- ref.read (byeDpiSettingsProvider.notifier).setPreset (id);
248+ // Merge this run's results into the on-disk cache (other strategies' prior
249+ // entries survive), so per-strategy dates and offline render work.
250+ Future <void > _persist (List <StrategyTestResult > results) async {
251+ final cache = await readTestResults ();
252+ for (final r in results) {
253+ cache[r.id] = {
254+ 'label' : r.label,
255+ 'percent' : r.percent,
256+ 'timestamp' : r.testedAt,
257+ 'sites' : [
258+ for (final s in r.sites)
259+ {'site' : s.site, 'ok' : s.ok, 'total' : s.total},
260+ ],
261+ };
262+ }
263+ await writeTestResults (cache);
264+ }
265+
266+ // Apply a strategy: make it active AND route its failed hosts via VPN by
267+ // writing them to the exclude list, then re-apply the profile so the byedpi
268+ // routing rules rebuild. Returns the byedpi/VPN split for the UI.
269+ Future <ApplySplit > apply (String id) async {
270+ final result = state.results.where ((r) => r.id == id).firstOrNull;
271+ final failed = result? .failedHosts ?? const < String > [];
272+ await writeExclude (failed);
273+ await ref.read (byeDpiSettingsProvider.notifier).setPreset (id);
274+ await appController.applyProfile (silence: true );
275+ final total = result? .sites.length ?? 0 ;
276+ return (byedpi: total - failed.length, vpn: failed.length);
277+ }
192278}
0 commit comments