feat(routing): 存档案后连接态自动重连使新规则生效
Task 9: RoutingProfileNotifier._persist() 保存成功后,若 connectionProvider.phase == on,触发 ConnectionController.reapplyRoutingProfile()(disconnect→connect, 不改选中节点)重连,让服务端按新档案渲染的规则生效;off 态不触发。 新增 l10n 键 routingRulesReconnecting(单源 design/i18n/strings.json → codegen), 「规则已更新,正在重连…」区别于既有 nodeReconnecting(弱网抖动语义)。 reapplyRoutingProfile 内先置 _userDisconnect=true 再 disconnect,避免其触发的 kernel off 事件被 _onKernelStatus 误判「意外掉线」、再抢跑一次 watchdog 自动重连。
This commit is contained in:
@@ -387,6 +387,7 @@ abstract class AppText {
|
||||
String get nodeUnhealthySwitched; // 智能选择已自动切换
|
||||
String get nodeUnhealthyError; // 手动节点:断开并提示
|
||||
String get nodeReconnecting; // 弱网抖动:自动重连当前节点中(#18)
|
||||
String get routingRulesReconnecting; // 分流档案保存后自动重连(Task 9)
|
||||
String get smartSub;
|
||||
String get recommended;
|
||||
|
||||
|
||||
@@ -89,6 +89,8 @@ class StringsEn extends AppText {
|
||||
@override
|
||||
String get nodeReconnecting => 'Network unstable, reconnecting…';
|
||||
@override
|
||||
String get routingRulesReconnecting => 'Rules updated, reconnecting…';
|
||||
@override
|
||||
String get smartSub => 'Picks the best node for your network';
|
||||
@override
|
||||
String get recommended => 'Recommended';
|
||||
|
||||
@@ -89,6 +89,8 @@ class StringsEs extends AppText {
|
||||
@override
|
||||
String get nodeReconnecting => 'Red inestable, reconectando…';
|
||||
@override
|
||||
String get routingRulesReconnecting => 'Reglas actualizadas, reconectando…';
|
||||
@override
|
||||
String get smartSub => 'Elige el mejor nodo para tu red';
|
||||
@override
|
||||
String get recommended => 'Recomendado';
|
||||
|
||||
@@ -89,6 +89,8 @@ class StringsJa extends AppText {
|
||||
@override
|
||||
String get nodeReconnecting => 'ネットワークが不安定です。再接続中…';
|
||||
@override
|
||||
String get routingRulesReconnecting => 'ルールが更新されました。再接続中…';
|
||||
@override
|
||||
String get smartSub => 'お使いのネットワークに最適なノードを選びます';
|
||||
@override
|
||||
String get recommended => 'おすすめ';
|
||||
|
||||
@@ -89,6 +89,8 @@ class StringsKo extends AppText {
|
||||
@override
|
||||
String get nodeReconnecting => '네트워크가 불안정하여 다시 연결 중…';
|
||||
@override
|
||||
String get routingRulesReconnecting => '규칙이 업데이트되어 다시 연결 중…';
|
||||
@override
|
||||
String get smartSub => '네트워크에 가장 적합한 노드를 선택합니다';
|
||||
@override
|
||||
String get recommended => '추천';
|
||||
|
||||
@@ -89,6 +89,8 @@ class StringsRu extends AppText {
|
||||
@override
|
||||
String get nodeReconnecting => 'Сеть нестабильна, переподключение…';
|
||||
@override
|
||||
String get routingRulesReconnecting => 'Правила обновлены, переподключение…';
|
||||
@override
|
||||
String get smartSub => 'Подбирает лучший узел для вашей сети';
|
||||
@override
|
||||
String get recommended => 'Рекомендуется';
|
||||
|
||||
@@ -89,6 +89,8 @@ class StringsZh extends AppText {
|
||||
@override
|
||||
String get nodeReconnecting => '网络波动,正在重连…';
|
||||
@override
|
||||
String get routingRulesReconnecting => '规则已更新,正在重连…';
|
||||
@override
|
||||
String get smartSub => '根据当前网络环境,自动选择最优节点';
|
||||
@override
|
||||
String get recommended => '推荐';
|
||||
|
||||
@@ -182,6 +182,24 @@ class ConnectionController extends StateNotifier<ConnectionState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 分流档案保存成功后触发(Task 9):仅当前已连接(on)才重连,使服务端按新档案
|
||||
/// 渲染的规则生效;off 态不触发(下次连接自然拿到新配置)。复刻 onNodeChanged 的
|
||||
/// disconnect→connect 序列(不改选中节点),附加「规则已更新」瞬态提示 ——
|
||||
/// 与 _tryAutoReconnectCurrent 同法:重连发起后若仍在握手/已连上(非 off),把
|
||||
/// error 字段临时置成提示文案,连上后随下一次 copyWith(仅 phase/elapsed)自然清除。
|
||||
Future<void> reapplyRoutingProfile() async {
|
||||
if (state.phase != VpnPhase.on) return;
|
||||
// 标记「非节点异常」的主动断开:否则 bridge.stop() 触发的 kernel off 事件会被
|
||||
// _onKernelStatus 当成意外掉线,拉着 _handleUnexpectedOff 再抢跑一次自动重连,
|
||||
// 与这里的 _connect() 撞车、多连一次(同 toggle() 主动断开时的用法)。
|
||||
_userDisconnect = true;
|
||||
await _disconnect();
|
||||
await _connect();
|
||||
if (mounted && state.phase != VpnPhase.off) {
|
||||
state = ConnectionState(phase: state.phase, error: _ref.read(appTextProvider).routingRulesReconnecting);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 内部 ─────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _connect() async {
|
||||
|
||||
@@ -10,11 +10,14 @@
|
||||
// **当前已存的 state**(此时已是乐观更新后的 next)重新 copyWithPrevious 一次,
|
||||
// 会把我们想还原的旧值又覆盖回 next,达不到「回退」效果。故失败时直接整体
|
||||
// `state = AsyncData(previous)`(纯回退,无错误标记)+ rethrow 传递异常。
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../models/routing_profile.dart';
|
||||
import 'account_providers.dart';
|
||||
import 'auth_provider.dart';
|
||||
import 'connection_provider.dart';
|
||||
|
||||
/// 当前账户的分流档案。未登录返回默认档案(不打后端)。
|
||||
class RoutingProfileNotifier extends AsyncNotifier<RoutingProfile> {
|
||||
@@ -81,6 +84,18 @@ class RoutingProfileNotifier extends AsyncNotifier<RoutingProfile> {
|
||||
state = AsyncData(previous);
|
||||
rethrow;
|
||||
}
|
||||
_reconnectIfConnected(); // Task 9:保存成功且已连接 → 重连使新规则生效
|
||||
}
|
||||
|
||||
/// 用 ref.read(非 watch)单向读取 connectionProvider:仅本 notifier 在保存成功后
|
||||
/// 顺带触发一次重连,不建立「connectionProvider 依赖 routingProfileProvider」的反向
|
||||
/// 引用,provider 依赖图仍是单向(routing → connection),不成环。off 态不触发——
|
||||
/// 下次连接本就会按最新档案渲染配置。重连失败由 connection_provider 自身状态承接
|
||||
/// (沿用其既有错误提示),不 rethrow 到这里,不影响「保存」这个操作本身的成败。
|
||||
void _reconnectIfConnected() {
|
||||
if (ref.read(connectionProvider).phase == VpnPhase.on) {
|
||||
unawaited(ref.read(connectionProvider.notifier).reapplyRoutingProfile());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
// routing_reconnect_test.dart — Task 9:分流档案保存成功后,若当前连接态为 on,
|
||||
// 应触发一次重连(disconnect→connect)使新规则生效;off 态不触发。
|
||||
//
|
||||
// 复用 routing_provider_test.dart 的登录桩 + connection_controller_test.dart /
|
||||
// flow_connect_test.dart 的连接驱动范式(VpnBridgeMock + MockClient 假 fetchConfig)。
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:pangolin_vpn/bridge/vpn_bridge_mock.dart';
|
||||
import 'package:pangolin_vpn/bridge/vpn_bridge_provider.dart';
|
||||
import 'package:pangolin_vpn/models/node.dart';
|
||||
import 'package:pangolin_vpn/models/routing_profile.dart';
|
||||
import 'package:pangolin_vpn/services/account_api.dart';
|
||||
import 'package:pangolin_vpn/services/api_client.dart';
|
||||
import 'package:pangolin_vpn/services/connect_api.dart';
|
||||
import 'package:pangolin_vpn/services/token_store.dart';
|
||||
import 'package:pangolin_vpn/state/account_providers.dart';
|
||||
import 'package:pangolin_vpn/state/auth_provider.dart';
|
||||
import 'package:pangolin_vpn/state/connection_provider.dart';
|
||||
import 'package:pangolin_vpn/state/nodes_provider.dart';
|
||||
import 'package:pangolin_vpn/state/routing_provider.dart';
|
||||
|
||||
/// 已登录的桩 TokenStore(仿 routing_provider_test.dart)。
|
||||
class _LoggedInTokenStore implements TokenStore {
|
||||
const _LoggedInTokenStore();
|
||||
@override
|
||||
Future<void> saveTokens({required String access, required String refresh}) async {}
|
||||
@override
|
||||
Future<String?> loadAccessToken() async => 'test-token';
|
||||
@override
|
||||
Future<String?> loadRefreshToken() async => 'test-refresh';
|
||||
@override
|
||||
Future<void> clear() async {}
|
||||
@override
|
||||
Future<void> markOnboarded() async {}
|
||||
@override
|
||||
Future<bool> isOnboarded() async => true;
|
||||
@override
|
||||
Future<void> saveLastEmail(String email) async {}
|
||||
@override
|
||||
Future<String?> loadLastEmail() async => null;
|
||||
}
|
||||
|
||||
/// 假 AccountApi:桩 routingProfile()/saveRoutingProfile(),记录调用便于断言。
|
||||
class _FakeAccountApi extends AccountApi {
|
||||
_FakeAccountApi()
|
||||
: super(ApiClient(
|
||||
baseUrl: 'http://test.local',
|
||||
getToken: () => null,
|
||||
refresh: () async => false,
|
||||
));
|
||||
|
||||
RoutingProfile initial = const RoutingProfile();
|
||||
final List<RoutingProfile> saved = [];
|
||||
|
||||
@override
|
||||
Future<RoutingProfile> routingProfile() async => initial;
|
||||
|
||||
@override
|
||||
Future<void> saveRoutingProfile(RoutingProfile p) async {
|
||||
saved.add(p);
|
||||
}
|
||||
}
|
||||
|
||||
/// 计数版 VpnBridgeMock:记录 start() 被调用的次数,用于断言「是否重连过」
|
||||
/// (重连 = disconnect→connect,即 start() 被再次调用)。
|
||||
class _CountingBridge extends VpnBridgeMock {
|
||||
_CountingBridge() : super(connectDelay: const Duration(milliseconds: 10));
|
||||
int startCount = 0;
|
||||
|
||||
@override
|
||||
Future<void> start(String configJson) async {
|
||||
startCount++;
|
||||
await super.start(configJson);
|
||||
}
|
||||
}
|
||||
|
||||
const _readyNode =
|
||||
Node(code: 'HK', nameZh: '香港', nameEn: 'Hong Kong', ping: 18, uuid: 'hk-01');
|
||||
|
||||
Future<void> _waitLoggedIn(ProviderContainer c) async {
|
||||
for (var i = 0; i < 60; i++) {
|
||||
if (c.read(authProvider).isLoggedIn) return;
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
}
|
||||
throw StateError('authProvider 未在预期时间内进入已登录态');
|
||||
}
|
||||
|
||||
Future<void> _until(bool Function() cond,
|
||||
{Duration timeout = const Duration(seconds: 2)}) async {
|
||||
final sw = Stopwatch()..start();
|
||||
while (!cond() && sw.elapsed < timeout) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 5));
|
||||
}
|
||||
}
|
||||
|
||||
ProviderContainer _makeContainer(_FakeAccountApi api, _CountingBridge bridge) {
|
||||
final mock = MockClient((req) async => http.Response('{"fake":"singbox-config"}', 200));
|
||||
return ProviderContainer(overrides: [
|
||||
tokenStoreProvider.overrideWithValue(const _LoggedInTokenStore()),
|
||||
accountApiProvider.overrideWithValue(api),
|
||||
effectiveNodeProvider.overrideWithValue(_readyNode),
|
||||
vpnBridgeProvider.overrideWithValue(bridge),
|
||||
connectApiFactoryProvider.overrideWithValue(
|
||||
(token) => ConnectApi(baseUrl: 'http://test.local', authToken: token, client: mock),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('连接态 on 时保存档案 → 触发重连(disconnect→connect)', () async {
|
||||
final api = _FakeAccountApi();
|
||||
final bridge = _CountingBridge();
|
||||
final c = _makeContainer(api, bridge);
|
||||
addTearDown(c.dispose);
|
||||
|
||||
await _waitLoggedIn(c);
|
||||
await c.read(routingProfileProvider.future);
|
||||
|
||||
// 先连上。
|
||||
c.read(connectionProvider.notifier).toggle();
|
||||
await _until(() => c.read(connectionProvider).phase == VpnPhase.on);
|
||||
expect(bridge.startCount, 1, reason: '首次连接应调用一次 start()');
|
||||
|
||||
// 保存档案(连接态 on)→ 应触发一次重连(再调 start())。
|
||||
await c.read(routingProfileProvider.notifier).save();
|
||||
await _until(() => bridge.startCount >= 2);
|
||||
expect(bridge.startCount, 2, reason: '保存成功且 on 态应重连一次(再次 start())');
|
||||
|
||||
// 重连期间应给出瞬态提示,并最终重新回到 on。
|
||||
await _until(() => c.read(connectionProvider).phase == VpnPhase.on);
|
||||
expect(c.read(connectionProvider).phase, VpnPhase.on, reason: '重连后应回到 on');
|
||||
});
|
||||
|
||||
test('连接态 off 时保存档案 → 不触发重连', () async {
|
||||
final api = _FakeAccountApi();
|
||||
final bridge = _CountingBridge();
|
||||
final c = _makeContainer(api, bridge);
|
||||
addTearDown(c.dispose);
|
||||
|
||||
await _waitLoggedIn(c);
|
||||
await c.read(routingProfileProvider.future);
|
||||
|
||||
expect(c.read(connectionProvider).phase, VpnPhase.off, reason: '未连接');
|
||||
|
||||
await c.read(routingProfileProvider.notifier).save();
|
||||
// 给可能的(不应发生的)异步重连留出时间窗口。
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
expect(bridge.startCount, 0, reason: 'off 态不应触发重连');
|
||||
expect(c.read(connectionProvider).phase, VpnPhase.off);
|
||||
});
|
||||
|
||||
test('addRule 触发的乐观保存(on 态)同样重连', () async {
|
||||
final api = _FakeAccountApi();
|
||||
final bridge = _CountingBridge();
|
||||
final c = _makeContainer(api, bridge);
|
||||
addTearDown(c.dispose);
|
||||
|
||||
await _waitLoggedIn(c);
|
||||
await c.read(routingProfileProvider.future);
|
||||
|
||||
c.read(connectionProvider.notifier).toggle();
|
||||
await _until(() => c.read(connectionProvider).phase == VpnPhase.on);
|
||||
expect(bridge.startCount, 1);
|
||||
|
||||
const rule = RoutingRule(type: 'domain_suffix', value: 'x.com', action: 'direct');
|
||||
await c.read(routingProfileProvider.notifier).addRule(rule);
|
||||
|
||||
await _until(() => bridge.startCount >= 2);
|
||||
expect(bridge.startCount, 2, reason: 'addRule 内部落盘同样应触发重连');
|
||||
});
|
||||
}
|
||||
@@ -313,6 +313,14 @@
|
||||
"ru": "Сеть нестабильна, переподключение…",
|
||||
"es": "Red inestable, reconectando…"
|
||||
},
|
||||
"routingRulesReconnecting": {
|
||||
"zh": "规则已更新,正在重连…",
|
||||
"en": "Rules updated, reconnecting…",
|
||||
"ja": "ルールが更新されました。再接続中…",
|
||||
"ko": "규칙이 업데이트되어 다시 연결 중…",
|
||||
"ru": "Правила обновлены, переподключение…",
|
||||
"es": "Reglas actualizadas, reconectando…"
|
||||
},
|
||||
"smartSub": {
|
||||
"zh": "根据当前网络环境,自动选择最优节点",
|
||||
"en": "Picks the best node for your network",
|
||||
|
||||
Reference in New Issue
Block a user