Compare commits

...

4 Commits

Author SHA1 Message Date
chen08209 e2f678909d Optimize commented policy 2026-08-17 14:50:35 +08:00
chen08209 7fb4f4f711 Fix whole group delay test failing on Windows 2026-08-17 14:49:53 +08:00
chen08209 903e2b8879 Optimize package icon loading and connections polling 2026-08-17 14:49:53 +08:00
chen08209 2df430ca31 Update changelog 2026-08-14 07:06:49 +00:00
50 changed files with 1307 additions and 689 deletions
+40
View File
@@ -21,6 +21,46 @@ Generated directories are excluded from analysis:
- `lib/**/generated/**`
- `plugins/**`
## Comments
Comments are opt-in and reserved for the few places that genuinely need one. Density is the point: every comment that
restates the code devalues the comments that carry real information, until readers skim past all of them. A file with
three comments that matter is more readable than one with thirty.
### Writing Comments
- Never add a comment on your own initiative. This covers explanatory, narrative, TODO, section-divider, and
documentation comments, in Dart, Kotlin, Swift, Go, Rust, YAML, Gradle, and any other file you touch.
- Never annotate line by line or statement by statement, and never restate in prose what the code already says. If a
block needs a comment per step, the block needs better names or a smaller decomposition instead.
- When a change genuinely cannot be understood without a comment, do not write it silently. Explain what is unclear,
propose the exact comment text, and wait for the user to approve it before adding it.
- Delete commented-out code, stale version notes, and comments that only restate the code, whenever you edit the file
that contains them. This does not need approval.
- These are not comments and must be preserved: analyzer and linter directives (`// ignore:`, `// ignore_for_file:`,
`// coverage:ignore`), license and copyright headers, code-generation markers, and comments inside vendored upstream
code such as `lib/widgets/open_container.dart`.
### Where Knowledge Belongs
Pick the destination by where the constraint would be violated, not by how important it feels.
- **Assertable behavior goes in a test.** A test is the only form that cannot drift, because it fails when the behavior
it describes is broken. Prefer it over both a comment and a document whenever the fact can be checked in code.
- **Repository-wide defaults, ownership, and invariants go in `.agents/*.md` or a `.agents/skills/*/SKILL.md`.** They
are violated from many files, so they must reach every future agent at session start. A comment in one file cannot do
that.
- **A fact that is true only at one call site, and is not visible from that call site, stays a comment there.** Its
value is being in the reader's line of sight at the moment of the edit. `lib/common/constant.dart` is the model case:
the delay-test concurrency cap is bound to `mBatch` in `core/common.go`, and whoever changes that number must see the
constraint on the same screen.
Both failure directions are real. Moving a local constraint into `.agents/` hides it from the person editing the line;
leaving a repo-wide policy as a comment reaches only the reader of that one file.
Before any of the three, prefer encoding the intent in structure and naming — a named mixin, type, or method that makes
the invariant hard to break beats prose that asks the next reader not to break it.
## Core API Safety
- Do not expose direct filesystem deletion APIs through Core or helper IPC; use
+5 -1
View File
@@ -25,7 +25,11 @@ Read these only when the task touches their area:
Do not require brainstorming, design documents, implementation plans, multiple-option proposals, or repeated confirmation.
Ask only when material ambiguity, destructive impact, additional authority, or scope expansion could change the result.
- Do not add code or configuration comments unless the user explicitly asks for comments. This includes explanatory,
narrative, TODO, and documentation comments.
narrative, TODO, and documentation comments. Never annotate line by line; comments belong only at the few key points
that cannot be understood without one, and there you must propose the exact text and wait for approval. Delete
commented-out code and stale notes whenever you touch the surrounding code. Put assertable behavior in a test,
repository-wide invariants in `.agents/`, and keep a comment only for a fact that is local to one call site.
See [.agents/rules.md](.agents/rules.md) for the full policy.
- Use `flutter test`, not `dart test`, because models pull in Flutter types.
- Run code generation after modifying models, providers, or database schema.
- Do not manually edit generated files.
+18
View File
@@ -1,3 +1,21 @@
## v0.8.95
- Optimize core service
- Optimize Android TV launcher icon
- Optimize back navigation
- Optimize more details
- Fix some issues
- Optimize app layout
- Optimize focus control
- Adjust android process
## v0.8.94
- Fix macos performance issue
-5
View File
@@ -19,9 +19,4 @@ extension ArchiveExt on Archive {
}
}
}
// void addTextFile<T>(String name, T raw) {
// final data = json.encode(raw);
// addFile(ArchiveFile.string(name, data));
// }
}
+5
View File
@@ -48,6 +48,11 @@ String _randomPipeId() {
final defaultTextScaleFactor =
WidgetsBinding.instance.platformDispatcher.textScaleFactor;
const httpTimeoutDuration = Duration(milliseconds: 5000);
/// Keep at or below the Core's delay-test concurrency (`mBatch` in
/// core/common.go). Surplus requests queue inside the Core behind a full wave
/// of 5s timeouts, which no RPC timeout can cover.
const maxConcurrentDelayTests = 50;
const moreDuration = Duration(milliseconds: 100);
const animateDuration = Duration(milliseconds: 100);
const midDuration = Duration(milliseconds: 200);
-15
View File
@@ -15,21 +15,6 @@ class BaseNavigator {
context,
).push<T>(CommonRoute(builder: (context) => child));
}
// static Future<T?> modal<T>(BuildContext context, Widget child) async {
// if (globalState.appState.viewMode != ViewMode.mobile) {
// return await globalState.showCommonDialog<T>(
// child: CommonModal(
// child: child,
// ),
// );
// }
// return await Navigator.of(context).push<T>(
// CommonRoute(
// builder: (context) => child,
// ),
// );
// }
}
const commonSharedXPageTransitions = SharedAxisPageTransitionsBuilder(
-48
View File
@@ -114,54 +114,6 @@ class NextClampingScrollPhysics extends ClampingScrollPhysics {
}
}
// class CacheScrollPositionController extends ScrollController {
// final String key;
//
// CacheScrollPositionController({
// required this.key,
// double initialScrollOffset = 0.0,
// super.keepScrollOffset = true,
// super.debugLabel,
// super.onAttach,
// super.onDetach,
// });
//
// @override
// ScrollPosition createScrollPosition(
// ScrollPhysics physics,
// ScrollContext context,
// ScrollPosition? oldPosition,
// ) {
// return ScrollPositionWithSingleContext(
// physics: physics,
// context: context,
// initialPixels:
// globalState.scrollPositionCache[key] ?? initialScrollOffset,
// keepScrollOffset: keepScrollOffset,
// oldPosition: oldPosition,
// debugLabel: debugLabel,
// );
// }
//
// double? get cacheOffset => globalState.scrollPositionCache[key];
//
// _handleScroll() {
// globalState.scrollPositionCache[key] = position.pixels;
// }
//
// @override
// void attach(ScrollPosition position) {
// super.attach(position);
// addListener(_handleScroll);
// }
//
// @override
// void detach(ScrollPosition position) {
// removeListener(_handleScroll);
// super.detach(position);
// }
// }
class ReverseScrollController extends ScrollController {
ReverseScrollController({
super.initialScrollOffset,
-4
View File
@@ -79,10 +79,6 @@ extension StringExtension on String {
return md5.convert(bytes).toString();
}
// bool containsToLower(String target) {
// return toLowerCase().contains(target);
// }
Future<T> commonToJSON<T>() async {
const thresholdLimit = 51200;
if (length < thresholdLimit) {
-3
View File
@@ -364,9 +364,6 @@ Future<MigrationData> _oldToNowTask(
configMap['appSettingProps'] = appSettingProps;
configMap['proxiesStyleProps'] = configMap['proxiesStyle'];
configMap['proxiesStyleProps'] = configMap['proxiesStyle'];
// final overwriteMap = configMap['overwrite'] as Map? ?? {};
// configMap['overwriteType'] = overwriteMap['type'];
// configMap['scriptId'] = overwriteMap['scriptOverwrite'];
List rawScripts = configMap['scripts'] as List<dynamic>? ?? [];
if (rawScripts.isEmpty) {
final scriptPropsJson = configMap['scriptProps'] as Map<String, dynamic>?;
-6
View File
@@ -184,12 +184,6 @@ class Utils {
return build1.compareTo(build2);
}
// String getPinyin(String value) {
// return value.isNotEmpty
// ? PinyinHelper.getFirstWordPinyin(value.substring(0, 1))
// : '';
// }
String? getFileNameForDisposition(String? disposition) {
if (disposition == null) return null;
final parseValue = HeaderValue.parse(disposition);
-1
View File
@@ -27,7 +27,6 @@ class Window {
protocol.register('flclash');
}
await windowManager.ensureInitialized();
// kDebugMode ? Size(680, 580) :
final WindowOptions windowOptions = WindowOptions(
size: props.size,
minimumSize: const Size(380, 400),
+85 -10
View File
@@ -6,9 +6,18 @@ import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/core/event.dart';
import 'package:fl_clash/core/method.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:flutter/foundation.dart' show visibleForTesting;
import 'transport.dart';
/// How many timeout windows a request may be extended across while Core keeps
/// answering other requests, before it fails regardless.
const _maxTimeoutExtension = 3;
const _maxExtensionGrace = Duration(seconds: 30);
const _connectTimeout = Duration(seconds: 10);
abstract interface class CoreRpcChannel {
Future<T?> invoke<T>({
required CoreMethod method,
@@ -21,6 +30,7 @@ abstract interface class CoreRpcChannel {
final class CoreRpcClient implements CoreRpcChannel {
final DesktopCoreTransport transport;
final Duration extensionGrace;
final Map<String, Completer<Object?>> _pending = {};
late final StreamSubscription<Uint8List> _frameSubscription;
late final StreamSubscription<DesktopTransportEvent> _eventSubscription;
@@ -28,7 +38,17 @@ final class CoreRpcClient implements CoreRpcChannel {
int _methodCallId = 0;
Future<void>? _closeOperation;
CoreRpcClient(this.transport) {
/// Time since Core last answered any request. A method timeout is a liveness
/// guard for a stalled link (a restart mid-flight, a stream that stopped
/// carrying data), not a budget for how long a single method may take — so a
/// request that is still waiting while Core demonstrably keeps replying to
/// its siblings gets its window re-armed instead of being failed.
final Stopwatch _sinceLastResponse = Stopwatch()..start();
CoreRpcClient(
this.transport, {
@visibleForTesting this.extensionGrace = _maxExtensionGrace,
}) {
_frameSubscription = transport.frames.listen(
_handleFrame,
onError: _handleFrameError,
@@ -57,23 +77,36 @@ final class CoreRpcClient implements CoreRpcChannel {
final stopwatch = Stopwatch()..start();
try {
await Future.any<Object?>([
transport.waitUntilConnected(const Duration(seconds: 10)),
transport.waitUntilConnected(_shorter(_connectTimeout, requestTimeout)),
completer.future,
]);
if (completer.isCompleted) {
return await completer.future as T?;
}
await transport.send(
json.encode(
CoreMethodCall(id: id, method: method, arguments: arguments),
),
);
final remainingTimeout = requestTimeout - stopwatch.elapsed;
if (remainingTimeout <= Duration.zero) {
final sendTimeout = requestTimeout - stopwatch.elapsed;
if (sendTimeout <= Duration.zero) {
throw TimeoutException('Core method ${method.name} timed out');
}
return await completer.future.timeout(remainingTimeout) as T?;
await transport
.send(
json.encode(
CoreMethodCall(id: id, method: method, arguments: arguments),
),
)
.timeout(sendTimeout);
return await _awaitResponse(
method: method,
completer: completer,
idleTimeout: requestTimeout,
elapsed: stopwatch,
)
as T?;
} on TimeoutException {
commonPrint.log(
'Core method ${method.name} timed out after '
'${stopwatch.elapsedMilliseconds}ms',
logLevel: LogLevel.warning,
);
_removePending(id, completer);
return null;
} on CoreMethodException {
@@ -91,6 +124,43 @@ final class CoreRpcClient implements CoreRpcChannel {
}
}
/// Waits for [completer], re-arming the window every time Core proves the
/// link is still alive by answering some other request, so a slow method is
/// not failed on behalf of a healthy link. Two things still end the wait:
/// [idleTimeout] of silence, and a hard deadline set by whichever of
/// [_maxTimeoutExtension] windows and [extensionGrace] on top binds first —
/// a Core answering everything except this request has to surface as well.
Future<Object?> _awaitResponse({
required CoreMethod method,
required Completer<Object?> completer,
required Duration idleTimeout,
required Stopwatch elapsed,
}) async {
final hardDeadline = _shorter(
idleTimeout * _maxTimeoutExtension,
idleTimeout + extensionGrace,
);
while (true) {
// Silence is measured from the last response or from this request's own
// start, whichever is later: an idle app must not shorten a fresh window.
final silence = _shorter(_sinceLastResponse.elapsed, elapsed.elapsed);
final remaining = _shorter(
idleTimeout - silence,
hardDeadline - elapsed.elapsed,
);
if (remaining <= Duration.zero) {
throw TimeoutException('Core method ${method.name} timed out');
}
try {
return await completer.future.timeout(remaining);
} on TimeoutException {
continue;
}
}
}
static Duration _shorter(Duration a, Duration b) => a < b ? a : b;
void _removePending(String id, Completer<Object?> completer) {
final removed = _pending.remove(id);
if (identical(removed, completer) && !completer.isCompleted) {
@@ -132,6 +202,11 @@ final class CoreRpcClient implements CoreRpcChannel {
}
void _handleResponse(CoreMethodResponse response) {
// Proof of life for every pending request, including a late response whose
// own caller already gave up.
_sinceLastResponse
..reset()
..start();
final id = response.id;
final completer = id == null ? null : _pending.remove(id);
if (completer == null || completer.isCompleted) {
-7
View File
@@ -53,13 +53,6 @@ class Database extends _$Database {
await _migrateRules(m);
}
},
beforeOpen: (details) async {
// final m = Migrator(this);
// await m.createTable(iconRecords);
// await _migrateRules(m);
// await m.deleteTable('proxy_groups');
// await m.createTable(proxyGroups);
},
);
}
-1
View File
@@ -95,7 +95,6 @@ class RulesDao extends DatabaseAccessor<Database> with _$RulesDaoMixin {
stmt.orderBy([
(t) => OrderingTerm.asc(t.scene),
//v0.8.92 ordering desc
(t) => OrderingTerm.desc(t.order),
(t) => OrderingTerm.desc(t.id),
]);
+1 -17
View File
@@ -146,10 +146,6 @@ enum RestoreOption { all, onlyProfiles }
enum ChipType { action, delete }
enum CommonCardType { plain, filled }
//
// extension CommonCardTypeExt on CommonCardType {
// CommonCardType get variant => CommonCardType.plain;
// }
enum ProxiesType { tab, list }
@@ -438,12 +434,7 @@ extension RuleActionExt on RuleAction {
enum OverrideRuleType { override, added }
enum OverwriteType {
// none,
standard,
script,
custom,
}
enum OverwriteType { standard, script, custom }
enum RuleTarget {
DIRECT,
@@ -451,13 +442,6 @@ enum RuleTarget {
static Set<String> get baseTargets =>
RuleTarget.values.map((item) => item.name).toSet();
// static bool isBaseRuleTarget(String? target) {
// return RuleTarget.values.indexWhere(
// (item) => item.name == target?.toUpperCase(),
// ) !=
// -1;
// }
}
enum RestoreStrategy { compatible, override }
-25
View File
@@ -140,36 +140,11 @@ class AppSidebarContainer extends ConsumerWidget {
const AppSidebarContainer({super.key, required this.child});
// Widget _buildLoading() {
// return Consumer(
// builder: (_, ref, _) {
// final loading = ref.watch(loadingProvider);
// final isMobileView = ref.watch(isMobileViewProvider);
// return loading && !isMobileView
// ? RotatedBox(
// quarterTurns: 1,
// child: const LinearProgressIndicator(),
// )
// : Container();
// },
// );
// }
Widget _buildBackground({
required BuildContext context,
required Widget child,
}) {
return Material(color: context.colorScheme.surfaceContainer, child: child);
// if (!system.isMacOS) {
// return Material(
// color: context.colorScheme.surfaceContainer,
// child: child,
// );
// }
// return child;
// return TransparentMacOSSidebar(
// child: Material(color: Colors.transparent, child: child),
// );
}
void _updateSideBarWidth(WidgetRef ref, double contentWidth) {
-62
View File
@@ -190,7 +190,6 @@ class StatusManagerState extends State<StatusManager> {
),
),
),
// LoadingIndicator(),
],
),
),
@@ -198,64 +197,3 @@ class StatusManagerState extends State<StatusManager> {
);
}
}
// class LoadingIndicator extends ConsumerWidget {
// const LoadingIndicator({super.key});
//
// @override
// Widget build(BuildContext context, ref) {
// final loading = ref.watch(loadingProvider);
// final isMobileView = ref.watch(isMobileViewProvider);
// return AnimatedSwitcher(
// switchInCurve: Curves.easeIn,
// switchOutCurve: Curves.easeOut,
// duration: midDuration,
// transitionBuilder: (Widget child, Animation<double> animation) {
// return SlideTransition(
// position: Tween<Offset>(
// begin: const Offset(1, 0),
// end: Offset.zero,
// ).animate(animation),
// child: child,
// );
// },
// child: loading && isMobileView
// ? Container(
// height: 54,
// margin: EdgeInsets.only(top: 8, left: 14, right: 14),
// child: Material(
// elevation: 3,
// color: context.colorScheme.surfaceContainer,
// surfaceTintColor: context.colorScheme.surfaceTint,
// shape: const RoundedSuperellipseBorder(
// borderRadius: BorderRadius.all(Radius.circular(14)),
// ),
// child: Padding(
// padding: EdgeInsets.symmetric(horizontal: 16),
// child: Row(
// mainAxisSize: MainAxisSize.min,
// spacing: 12,
// mainAxisAlignment: MainAxisAlignment.spaceBetween,
// children: [
// Flexible(
// child: Text(
// context.appLocalizations.loading,
// style: context.textTheme.labelLarge?.copyWith(
// color: context.colorScheme.onSurfaceVariant,
// ),
// ),
// ),
// SizedBox(
// height: 32,
// width: 32,
// child: CommonCircleLoading(),
// ),
// ],
// ),
// ),
// ),
// )
// : SizedBox(),
// );
// }
// }
-16
View File
@@ -42,22 +42,6 @@ class ThemeManager extends ConsumerWidget {
);
}
// _buildScrollbar(Widget child) {
// return Consumer(
// builder: (_, ref, child) {
// final isMobileView = ref.read(isMobileViewProvider);
// if (isMobileView) {
// return ScrollConfiguration(
// behavior: HiddenBarScrollBehavior(),
// child: child!,
// );
// }
// return child!;
// },
// child: child,
// );
// }
@override
Widget build(BuildContext context, ref) {
final textScale = ref.read(
-3
View File
@@ -233,9 +233,6 @@ class _WindowHeaderState extends State<WindowHeader> {
},
icon: const Icon(Icons.close),
),
// const SizedBox(
// width: 8,
// ),
],
);
}
-45
View File
@@ -333,10 +333,6 @@ abstract class Rule with _$Rule {
String? order,
}) = _Rule;
// factory Rule.parseString(String? value) {
// return Rule.parse(Rule.value(value ?? ''));
// }
factory Rule.init() {
return Rule(
ruleAction: RuleAction.DOMAIN,
@@ -442,39 +438,6 @@ extension RuleExt on Rule {
}
}
// @freezed
// abstract class Rule with _$Rule {
// const factory Rule({required int id, required String value, String? order}) =
// _Rule;
//
// factory Rule.value(String value) {
// return Rule(value: value, id: snowflake.id);
// }
//
// factory Rule.fromJson(Map<String, Object?> json) => _$RuleFromJson(json);
// }
//
// extension RulesExt on List<Rule> {
// List<Rule> copyAndPut(Rule rule) {
// var newList = List<Rule>.from(this);
// final index = newList.indexWhere((item) => item.id == rule.id);
// if (index != -1) {
// rule = newList[index] = rule;
// } else {
// newList.insert(0, rule);
// }
// return newList;
// }
// }
// @freezed
// abstract class SubRule with _$SubRule {
// const factory SubRule({required String name}) = _SubRule;
//
// factory SubRule.fromJson(Map<String, Object?> json) =>
// _$SubRuleFromJson(json);
// }
//
List<Rule> _genRules(List<dynamic>? rules) {
if (rules == null) {
return [];
@@ -482,14 +445,6 @@ List<Rule> _genRules(List<dynamic>? rules) {
return rules.map((item) => Rule.parse(item)).toList();
}
// List<RuleProvider> _genRuleProviders(Map<String, dynamic> json) {
// return json.entries.map((entry) => RuleProvider(name: entry.key)).toList();
// }
//
// List<SubRule> _genSubRules(Map<String, dynamic> json) {
// return json.entries.map((entry) => SubRule(name: entry.key)).toList();
// }
List<String> _genList(Map<String, dynamic> json) {
return json.entries.map((entry) => entry.key).toList();
}
+1 -10
View File
@@ -140,25 +140,16 @@ String _logDateTime(dynamic _) {
return DateTime.now().showFull;
}
// String _logId(_) {
// return utils.id;
// }
@freezed
abstract class Log with _$Log {
const factory Log({
// @JsonKey(fromJson: _logId) required String id,
@JsonKey(name: 'LogLevel') @Default(LogLevel.info) LogLevel logLevel,
@JsonKey(name: 'Payload') @Default('') String payload,
@JsonKey(fromJson: _logDateTime) required String dateTime,
}) = _Log;
factory Log.app(String payload) {
return Log(
payload: payload,
dateTime: _logDateTime(null),
// id: _logId(null),
);
return Log(payload: payload, dateTime: _logDateTime(null));
}
factory Log.fromJson(Map<String, Object?> json) => _$LogFromJson(json);
-25
View File
@@ -93,18 +93,6 @@ extension ProfileRuleLinkExt on ProfileRuleLink {
}
}
// @freezed
// abstract class Overwrite with _$Overwrite {
// const factory Overwrite({
// @Default(OverwriteType.standard) OverwriteType type,
// @Default(StandardOverwrite()) StandardOverwrite standardOverwrite,
// @Default(ScriptOverwrite()) ScriptOverwrite scriptOverwrite,
// }) = _Overwrite;
//
// factory Overwrite.fromJson(Map<String, Object?> json) =>
// _$OverwriteFromJson(json);
// }
@freezed
abstract class StandardOverwrite with _$StandardOverwrite {
const factory StandardOverwrite({
@@ -178,19 +166,6 @@ extension ProfileExtension on Profile {
return file.create(recursive: true);
}
return file;
// final oldPath = await appPath.getProfilePath(id);
// final newPath = await appPath.getProfilePath(fileName);
// final oldFile = oldPath == newPath ? null : File(oldPath);
// final oldIsExists = await oldFile?.exists() ?? false;
// if (oldIsExists) {
// return await oldFile!.rename(newPath);
// }
// final file = File(newPath);
// final isExists = await file.exists();
// if (!isExists && autoCreate) {
// return await file.create(recursive: true);
// }
// return file;
}
Future<File> get file async {
-39
View File
@@ -369,42 +369,3 @@ abstract class SetupState with _$SetupState {
required Dns dns,
}) = _SetupState;
}
// extension SetupStateExt on SetupState {
// bool needSetup(SetupState? lastSetupState) {
// if (lastSetupState == null) {
// return false;
// }
// if (profileId != lastSetupState.profileId) {
// return true;
// }
// if (profileLastUpdateDate != lastSetupState.profileLastUpdateDate) {
// return true;
// }
// final scriptIsChange = script != lastSetupState.script;
// if (overwriteType != lastSetupState.overwriteType) {
// if (!ruleListEquality.equals(addedRules, lastSetupState.addedRules) ||
// scriptIsChange) {
// return true;
// }
// } else {
// if (overwriteType == OverwriteType.script) {
// if (scriptIsChange) {
// return true;
// }
// }
// if (overwriteType == OverwriteType.standard) {
// if (!ruleListEquality.equals(addedRules, lastSetupState.addedRules)) {
// return true;
// }
// }
// }
// if (overrideDns != lastSetupState.overrideDns) {
// return true;
// }
// if (overrideDns == true && dns != lastSetupState.dns) {
// return true;
// }
// return false;
// }
// }
+39 -7
View File
@@ -61,14 +61,46 @@ class App {
false;
}
Future<ImageProvider?> getPackageIcon(String packageName) async {
final path = await methodChannel.invokeMethod<String>('getPackageIcon', {
'packageName': packageName,
});
if (path == null) {
return null;
final Map<String, ImageProvider?> _packageIcons = {};
final Map<String, Future<ImageProvider?>> _packageIconTasks = {};
bool hasPackageIcon(String packageName) {
return _packageIcons.containsKey(packageName);
}
ImageProvider? getCachedPackageIcon(String packageName) {
return _packageIcons[packageName];
}
Future<ImageProvider?> getPackageIcon(String packageName) {
if (packageName.isEmpty) {
return Future.value(null);
}
return FileImage(File(path));
if (_packageIcons.containsKey(packageName)) {
return Future.value(_packageIcons[packageName]);
}
return _packageIconTasks[packageName] ??= _loadPackageIcon(packageName);
}
Future<ImageProvider?> _loadPackageIcon(String packageName) async {
ImageProvider? icon;
try {
final path = await methodChannel.invokeMethod<String>('getPackageIcon', {
'packageName': packageName,
});
icon = path == null ? null : FileImage(File(path));
} catch (error) {
commonPrint.log('getPackageIcon error: $error');
}
_packageIcons[packageName] = icon;
_packageIconTasks.remove(packageName);
return icon;
}
@visibleForTesting
void clearPackageIconCache() {
_packageIcons.clear();
_packageIconTasks.clear();
}
Future<bool?> tip(String? message) async {
-3
View File
@@ -500,9 +500,6 @@ ColorScheme genColorScheme(
),
);
if (color == null && (ignoreConfig == true || vm2.a == null)) {
// if (globalState.corePalette != null) {
// return globalState.corePalette!.toColorScheme(brightness: brightness);
// }
return ColorScheme.fromSeed(
seedColor:
globalState.corePalette
-3
View File
@@ -199,9 +199,6 @@ class Avatar extends StatelessWidget {
Text(contributor.name, style: context.textTheme.bodySmall),
],
),
// onTap: () {
// globalState.openUrl(contributor.link);
// },
);
}
}
+1 -19
View File
@@ -454,25 +454,7 @@ class PackageListItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ListItem.checkbox(
leading: SizedBox(
width: 48,
height: 48,
child: FutureBuilder<ImageProvider?>(
future: app?.getPackageIcon(package.packageName),
builder: (_, snapshot) {
if (!snapshot.hasData && snapshot.data == null) {
return Container();
} else {
return Image(
image: snapshot.data!,
gaplessPlayback: true,
width: 48,
height: 48,
);
}
},
),
),
leading: PackageIcon(packageName: package.packageName, size: 48),
title: Text(
package.label,
style: const TextStyle(overflow: TextOverflow.ellipsis),
+59 -54
View File
@@ -1,5 +1,3 @@
import 'dart:async';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/core/controller.dart';
import 'package:fl_clash/core/method.dart';
@@ -12,26 +10,30 @@ import 'package:super_sliver_list/super_sliver_list.dart';
import 'item.dart';
class ConnectionsView extends ConsumerStatefulWidget {
const ConnectionsView({super.key});
final Future<List<TrackerInfo>> Function()? connectionsReader;
const ConnectionsView({super.key, @visibleForTesting this.connectionsReader});
@override
ConsumerState<ConnectionsView> createState() => _ConnectionsViewState();
}
class _ConnectionsViewState extends ConsumerState<ConnectionsView> {
class _ConnectionsViewState extends ConsumerState<ConnectionsView>
with WidgetsBindingObserver, ActivePollingMixin<ConnectionsView> {
final _connectionsStateNotifier = ValueNotifier<TrackerInfosState>(
const TrackerInfosState(),
);
final ScrollController _scrollController = ScrollController();
Timer? timer;
@override
Duration get pollInterval => const Duration(seconds: 1);
List<Widget> _buildActions() {
return [
IconButton(
onPressed: () async {
coreController.closeConnections();
await _updateConnections();
await _refreshConnections();
},
icon: const Icon(Icons.delete_sweep_outlined),
),
@@ -50,46 +52,53 @@ class _ConnectionsViewState extends ConsumerState<ConnectionsView> {
);
}
Future<void> _updateConnectionsTask() async {
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (mounted) {
await _updateConnections();
timer = Timer(const Duration(seconds: 1), () async {
_updateConnectionsTask();
});
}
});
}
@override
void initState() {
super.initState();
_updateConnectionsTask();
Future<void> poll(PollGuard isCurrent) async {
final trackerInfos = await _readConnections();
if (trackerInfos == null || !isCurrent()) {
return;
}
_applyConnections(trackerInfos);
}
Future<void> _updateConnections() async {
Future<void> _refreshConnections() async {
final trackerInfos = await _readConnections();
if (trackerInfos == null || !mounted) {
return;
}
_applyConnections(trackerInfos);
}
Future<List<TrackerInfo>?> _readConnections() async {
try {
_connectionsStateNotifier.value = _connectionsStateNotifier.value
.copyWith(trackerInfos: await coreController.getConnections());
final connectionsReader = widget.connectionsReader;
return connectionsReader != null
? await connectionsReader()
: await coreController.getConnections();
} catch (error) {
commonPrint.log(
'updateConnections error: $error',
logLevel: coreFailureLogLevel(error),
);
return null;
}
}
void _applyConnections(List<TrackerInfo> trackerInfos) {
_connectionsStateNotifier.value = _connectionsStateNotifier.value.copyWith(
trackerInfos: trackerInfos,
);
}
Future<void> _handleBlockConnection(String id) async {
await coreController.closeConnection(id);
await _updateConnections();
await _refreshConnections();
}
@override
void dispose() {
timer?.cancel();
_connectionsStateNotifier.dispose();
_scrollController.dispose();
timer = null;
super.dispose();
}
@@ -111,36 +120,32 @@ class _ConnectionsViewState extends ConsumerState<ConnectionsView> {
illustration: const ConnectionEmptyIllustration(),
);
}
final items = connections
.map<Widget>(
(trackerInfo) => TrackerInfoItem(
key: Key(trackerInfo.id),
trackerInfo: trackerInfo,
onClickKeyword: (value) {
context.commonScaffoldState?.addKeyword(value);
},
trailing: IconButton(
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
style: IconButton.styleFrom(minimumSize: Size.zero),
icon: const Icon(Icons.block),
onPressed: () {
_handleBlockConnection(trackerInfo.id);
},
),
detailTitle: appLocalizations.details(
appLocalizations.connection,
),
),
)
.separated(const Divider(height: 0))
.toList();
return SuperListView.builder(
return SuperListView.separated(
controller: _scrollController,
itemBuilder: (context, index) {
return items[index];
},
itemCount: connections.length,
separatorBuilder: (_, _) => const Divider(height: 0),
itemBuilder: (_, index) {
final trackerInfo = connections[index];
return TrackerInfoItem(
key: Key(trackerInfo.id),
trackerInfo: trackerInfo,
onClickKeyword: (value) {
context.commonScaffoldState?.addKeyword(value);
},
trailing: IconButton(
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
style: IconButton.styleFrom(minimumSize: Size.zero),
icon: const Icon(Icons.block),
onPressed: () {
_handleBlockConnection(trackerInfo.id);
},
),
detailTitle: appLocalizations.details(
appLocalizations.connection,
),
);
},
);
},
),
+3 -35
View File
@@ -1,7 +1,6 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/plugins/app.dart';
import 'package:fl_clash/providers/config.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/widgets/widgets.dart';
@@ -26,10 +25,6 @@ class TrackerInfoItem extends ConsumerWidget {
return globalState.measure.bodySmallHeight + 20;
}
Future<ImageProvider?> _getPackageIcon(TrackerInfo connection) async {
return await app?.getPackageIcon(connection.metadata.process);
}
String _getSourceText(BuildContext context, TrackerInfo trackerInfo) {
final progress = trackerInfo.progressText.isNotEmpty
? '${trackerInfo.progressText} · '
@@ -50,22 +45,6 @@ class TrackerInfoItem extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(trackerInfo.desc, style: context.textTheme.bodyLarge),
// Row(
// mainAxisSize: MainAxisSize.max,
// mainAxisAlignment: MainAxisAlignment.spaceBetween,
// spacing: 8,
// children: [
// Flexible(
// child: Text(trackerInfo.desc, style: context.textTheme.bodyLarge),
// ),
// Text(
// trackerInfo.start.lastUpdateTimeDesc,
// style: context.textTheme.bodySmall?.copyWith(
// color: context.colorScheme.onSurface.opacity60,
// ),
// ),
// ],
// ),
const SizedBox(height: 6),
Text(
_getSourceText(context, trackerInfo),
@@ -121,20 +100,9 @@ class TrackerInfoItem extends ConsumerWidget {
margin: const EdgeInsets.only(top: 4),
width: 42,
height: 42,
child: FutureBuilder<ImageProvider?>(
future: _getPackageIcon(trackerInfo),
builder: (_, snapshot) {
if (!snapshot.hasData && snapshot.data == null) {
return Container();
} else {
return Image(
image: snapshot.data!,
gaplessPlayback: true,
width: 42,
height: 42,
);
}
},
child: PackageIcon(
packageName: trackerInfo.metadata.process,
size: 42,
),
),
)
+14 -18
View File
@@ -118,21 +118,6 @@ class _RequestsViewState extends ConsumerState<RequestsView> {
label: appLocalizations.nullTip(appLocalizations.requests),
);
}
final items = requests
.map<Widget>(
(trackerInfo) => TrackerInfoItem(
key: Key(trackerInfo.id),
trackerInfo: trackerInfo,
onClickKeyword: (value) {
context.commonScaffoldState?.addKeyword(value);
},
detailTitle: appLocalizations.details(
appLocalizations.request,
),
),
)
.separated(const Divider(height: 0))
.toList();
return Align(
alignment: Alignment.topCenter,
child: CommonScrollBar(
@@ -146,15 +131,26 @@ class _RequestsViewState extends ConsumerState<RequestsView> {
_requestsStateNotifier.value = _requestsStateNotifier.value
.copyWith(autoScrollToEnd: false);
},
child: SuperListView.builder(
child: SuperListView.separated(
reverse: true,
shrinkWrap: true,
physics: const NextClampingScrollPhysics(),
controller: _scrollController,
itemCount: requests.length,
separatorBuilder: (_, _) => const Divider(height: 0),
itemBuilder: (_, index) {
return items[index];
final trackerInfo = requests[index];
return TrackerInfoItem(
key: Key(trackerInfo.id),
trackerInfo: trackerInfo,
onClickKeyword: (value) {
context.commonScaffoldState?.addKeyword(value);
},
detailTitle: appLocalizations.details(
appLocalizations.request,
),
);
},
itemCount: items.length,
),
),
),
+22 -76
View File
@@ -1,8 +1,8 @@
import 'dart:async';
import 'dart:io';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/core/controller.dart';
import 'package:fl_clash/core/method.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/state.dart';
@@ -20,85 +20,31 @@ class MemoryInfo extends StatefulWidget {
State<MemoryInfo> createState() => _MemoryInfoState();
}
class _MemoryInfoState extends State<MemoryInfo> with WidgetsBindingObserver {
Timer? _timer;
bool _isForeground = false;
bool _isUpdating = false;
int _updateGeneration = 0;
class _MemoryInfoState extends State<MemoryInfo>
with WidgetsBindingObserver, ActivePollingMixin<MemoryInfo> {
@override
Duration get pollInterval => const Duration(seconds: 2);
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_isForeground =
WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _isForeground) {
_startUpdating();
}
});
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_isForeground = false;
_stopUpdating();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
final isForeground = state == AppLifecycleState.resumed;
if (_isForeground == isForeground) {
return;
}
_isForeground = isForeground;
if (isForeground) {
_startUpdating();
} else {
_stopUpdating();
}
}
void _startUpdating() {
if (!mounted || !_isForeground || _isUpdating) {
return;
}
_isUpdating = true;
final generation = ++_updateGeneration;
unawaited(_updateMemory(generation));
}
void _stopUpdating() {
_isUpdating = false;
_updateGeneration++;
_timer?.cancel();
_timer = null;
}
Future<void> _updateMemory(int generation) async {
final memoryReader = widget.memoryReader;
final memory = memoryReader != null
? await memoryReader()
: await _readMemory();
if (!mounted ||
!_isForeground ||
!_isUpdating ||
generation != _updateGeneration) {
Future<void> poll(PollGuard isCurrent) async {
final memory = await _readMemory();
if (memory == null || !isCurrent()) {
return;
}
_memoryStateNotifier.value = memory;
_timer = Timer(const Duration(seconds: 2), () {
_timer = null;
if (mounted &&
_isForeground &&
_isUpdating &&
generation == _updateGeneration) {
unawaited(_updateMemory(generation));
}
});
}
Future<num?> _readMemory() async {
try {
final memoryReader = widget.memoryReader;
return memoryReader != null ? await memoryReader() : await _readTotal();
} catch (error) {
commonPrint.log(
'updateMemory error: $error',
logLevel: coreFailureLogLevel(error),
);
return null;
}
}
@override
@@ -156,7 +102,7 @@ class _MemoryInfoState extends State<MemoryInfo> with WidgetsBindingObserver {
}
}
Future<num> _readMemory() async {
Future<num> _readTotal() async {
final rss = ProcessInfo.currentRss;
final coreConnected =
globalState.container.read(coreStatusProvider) == CoreStatus.connected;
@@ -179,18 +179,6 @@ class OutboundModeV2 extends StatelessWidget {
height: 8.ap,
width: constraints.maxWidth,
padding: const EdgeInsets.symmetric(horizontal: 16),
// child: Row(
// children: [
// Container(
// width: (constraints.maxWidth - 32) / 3,
// height: 3,
// decoration: BoxDecoration(
// color: _getTextColor(context, mode),
// borderRadius: BorderRadius.circular(2),
// ),
// ),
// ],
// ),
),
],
);
-9
View File
@@ -69,15 +69,6 @@ class DeveloperView extends ConsumerWidget {
.handleClear();
},
),
// ListItem(
// title: Text(appLocalizations.loadTest),
// minVerticalPadding: 12,
// onTap: () {
// ref.read(loadingProvider.notifier).value = !ref.read(
// loadingProvider,
// );
// },
// ),
ListItem(
title: Text(appLocalizations.pruneCache),
minVerticalPadding: 12,
-13
View File
@@ -24,22 +24,9 @@ class _ProfilesViewState extends State<ProfilesView> {
Function? applyConfigDebounce;
bool _isUpdating = false;
// final GlobalKey _targetKey = GlobalKey();
@override
void initState() {
super.initState();
// WidgetsBinding.instance.addPostFrameCallback((_) {
// final context = _targetKey.currentContext;
// if (context == null) {
// return;
// }
// Scrollable.ensureVisible(
// context,
// duration: commonDuration,
// alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd,
// );
// });
}
void _handleShowAddExtendPage() {
+1 -1
View File
@@ -80,7 +80,7 @@ Future<void> proxyDelayTest(Proxy proxy, [String? testUrl]) async {
}
Future<void> delayTest(List<Proxy> proxies, [String? testUrl]) async {
final batches = proxies.batch(100);
final batches = proxies.batch(maxConcurrentDelayTests);
for (final batch in batches) {
await Future.wait(
batch.map((proxy) async {
+129
View File
@@ -0,0 +1,129 @@
import 'dart:async';
import 'package:fl_clash/common/print.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/widgets/inherited.dart';
import 'package:flutter/widgets.dart';
typedef PollGuard = bool Function();
mixin ActivePollingMixin<T extends StatefulWidget>
on State<T>, WidgetsBindingObserver {
Timer? _pollTimer;
bool _isForeground = false;
bool _isPageActive = true;
bool _isPolling = false;
int _pollGeneration = 0;
Duration get pollInterval;
bool get pollOnStart => true;
Future<void> poll(PollGuard isCurrent);
bool get canPoll => mounted && _isForeground && _isPageActive;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
final lifecycleState = WidgetsBinding.instance.lifecycleState;
_isForeground =
lifecycleState == null || lifecycleState == AppLifecycleState.resumed;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_syncPolling();
}
});
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final isPageActive = PageActivityScope.isActiveOf(context);
if (_isPageActive == isPageActive) {
return;
}
_isPageActive = isPageActive;
_syncPolling();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
final isForeground = state == AppLifecycleState.resumed;
if (_isForeground == isForeground) {
return;
}
_isForeground = isForeground;
_syncPolling();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_isForeground = false;
stopPolling();
super.dispose();
}
void startPolling() {
if (!canPoll || _isPolling) {
return;
}
_isPolling = true;
final generation = ++_pollGeneration;
if (pollOnStart) {
unawaited(_runPoll(generation));
return;
}
_schedulePoll(generation);
}
void stopPolling() {
_isPolling = false;
_pollGeneration++;
_pollTimer?.cancel();
_pollTimer = null;
}
void restartPolling() {
stopPolling();
_syncPolling();
}
void _syncPolling() {
if (canPoll) {
startPolling();
} else {
stopPolling();
}
}
bool _isCurrentPoll(int generation) =>
canPoll && _isPolling && generation == _pollGeneration;
void _schedulePoll(int generation) {
_pollTimer = Timer(pollInterval, () {
_pollTimer = null;
if (_isCurrentPoll(generation)) {
unawaited(_runPoll(generation));
}
});
}
Future<void> _runPoll(int generation) async {
try {
await poll(() => _isCurrentPoll(generation));
} catch (error) {
commonPrint.log(
'$runtimeType poll error: $error',
logLevel: LogLevel.warning,
);
} finally {
if (_isCurrentPoll(generation)) {
_schedulePoll(generation);
}
}
}
}
+14 -24
View File
@@ -1,5 +1,4 @@
import 'dart:async';
import 'package:fl_clash/widgets/active_polling.dart';
import 'package:fl_clash/widgets/inherited.dart';
import 'package:flutter/material.dart';
@@ -16,40 +15,31 @@ class TickBuilder extends StatefulWidget {
State<TickBuilder> createState() => _TickBuilderState();
}
class _TickBuilderState extends State<TickBuilder> {
Timer? _timer;
class _TickBuilderState extends State<TickBuilder>
with WidgetsBindingObserver, ActivePollingMixin<TickBuilder> {
int _tick = 0;
@override
void initState() {
super.initState();
_startTimer();
Duration get pollInterval => widget.duration;
@override
bool get pollOnStart => false;
@override
Future<void> poll(PollGuard isCurrent) async {
setState(() {
_tick++;
});
}
@override
void didUpdateWidget(covariant TickBuilder oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.duration != widget.duration) {
_startTimer();
restartPolling();
}
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
void _startTimer() {
_timer?.cancel();
_timer = Timer.periodic(widget.duration, (_) {
if (!mounted) return;
setState(() {
_tick++;
});
});
}
@override
Widget build(BuildContext context) {
return widget.builder(context, _tick);
-10
View File
@@ -154,16 +154,6 @@ class CommonCard extends StatelessWidget {
Color? _buildBackgroundColor(BuildContext context) {
final colorScheme = context.colorScheme;
// if (isError) {
// if (type == CommonCardType.filled) {
// return isSelected
// ? colorScheme.errorContainer.opacity80
// : colorScheme.errorContainer;
// }
// return isSelected
// ? colorScheme.errorContainer.opacity60
// : colorScheme.errorContainer.opacity12;
// }
if (type == CommonCardType.filled) {
if (isSelected) {
return colorScheme.secondaryContainer.opacity80;
+67
View File
@@ -4,6 +4,7 @@ import 'dart:io';
import 'package:fl_clash/common/cache.dart';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/database/database.dart';
import 'package:fl_clash/plugins/app.dart';
import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_svg/svg.dart';
@@ -125,6 +126,72 @@ class _ImageCacheWidgetState extends State<ImageCacheWidget> {
}
}
class PackageIcon extends StatefulWidget {
final String packageName;
final double size;
const PackageIcon({super.key, required this.packageName, required this.size});
@override
State<PackageIcon> createState() => _PackageIconState();
}
class _PackageIconState extends State<PackageIcon> {
ImageProvider? _icon;
int _generation = 0;
@override
void initState() {
super.initState();
_loadIcon();
}
@override
void didUpdateWidget(covariant PackageIcon oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.packageName != widget.packageName) {
_loadIcon();
}
}
void _loadIcon() {
final generation = ++_generation;
final packageName = widget.packageName;
final currentApp = app;
if (currentApp == null || packageName.isEmpty) {
_icon = null;
return;
}
if (currentApp.hasPackageIcon(packageName)) {
_icon = currentApp.getCachedPackageIcon(packageName);
return;
}
_icon = null;
currentApp.getPackageIcon(packageName).then((icon) {
if (!mounted || generation != _generation || icon == null) {
return;
}
setState(() {
_icon = icon;
});
});
}
@override
Widget build(BuildContext context) {
final icon = _icon;
if (icon == null) {
return SizedBox(width: widget.size, height: widget.size);
}
return Image(
image: icon,
gaplessPlayback: true,
width: widget.size,
height: widget.size,
);
}
}
class CommonImage extends StatelessWidget {
final File data;
final bool isSvg;
-2
View File
@@ -203,12 +203,10 @@ class _ThemeAwareSvg extends StatelessWidget {
'#E8DEF8',
'#${_colorToHex(colorScheme.secondaryContainer)}',
);
// primary ??
svgString = svgString.replaceAll(
'#6750A4',
'#${_colorToHex(colorScheme.primary)}',
);
// surface ??
svgString = svgString.replaceAll(
'#FDF7FF',
'#${_colorToHex(colorScheme.surface)}',
-25
View File
@@ -628,28 +628,3 @@ Future<T?> showModalSideSheet<T>({
),
);
}
// class ModalAppBar extends StatelessWidget {
// final String title;
//
// const ModalAppBar({
// super.key,
// required this.title,
// });
//
// @override
// Widget build(BuildContext context) {
// return AppBar(
// automaticallyImplyLeading: false,
// title: Text(title),
// centerTitle: false,
// actions: const [
// SizedBox(
// height: kToolbarHeight,
// width: kToolbarHeight,
// child: CloseButton(),
// )
// ],
// );
// }
// }
-16
View File
@@ -130,19 +130,3 @@ class EmojiText extends StatelessWidget {
);
}
}
// class HighlightText extends StatelessWidget {
// const HighlightText({super.key});
//
// @override
// Widget build(BuildContext context) {
// return RichText(
// textScaler: MediaQuery.of(context).textScaler,
// maxLines: maxLines,
// overflow: overflow ?? TextOverflow.clip,
// text: TextSpan(
// children: _buildTextSpans(text),
// ),
// );
// }
// }
+1
View File
@@ -1,4 +1,5 @@
export 'activate_box.dart';
export 'active_polling.dart';
export 'animate_grid.dart';
export 'animated_visibility.dart';
export 'builder.dart';
+265 -18
View File
@@ -4,11 +4,11 @@ use flutter_rust_bridge::frb;
use interprocess::local_socket::prelude::*;
use interprocess::local_socket::{GenericFilePath, ListenerNonblockingMode, ListenerOptions};
use std::io::{self, Read, Write};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::mpsc::{self, SyncSender, TrySendError};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
use std::time::{Duration, Instant};
#[cfg(unix)]
use std::path::Path;
@@ -31,12 +31,12 @@ static RUNNING: AtomicBool = AtomicBool::new(false);
static LIFECYCLE: Mutex<()> = Mutex::new(());
struct ServerState {
tx: Option<SyncSender<Vec<u8>>>,
sender: Option<MessageSender>,
handle: Option<thread::JoinHandle<()>>,
}
static STATE: Mutex<ServerState> = Mutex::new(ServerState {
tx: None,
sender: None,
handle: None,
});
@@ -47,9 +47,54 @@ const TYPE_DATA: u8 = 0x03;
const TYPE_ERROR: u8 = 0x04;
const MAX_FRAME_SIZE: usize = 64 * 1024 * 1024;
const MAX_PENDING_MESSAGES: usize = 8;
const MAX_PENDING_MESSAGES: usize = 256;
// The slot count alone bounds nothing: 256 slots times the frame ceiling is
// gigabytes. Real frames are control messages of a few KB, so cap the queue by
// bytes too and let the message count stay generous for fan-out bursts.
const MAX_PENDING_BYTES: usize = 16 * 1024 * 1024;
const IO_POLL_INTERVAL: Duration = Duration::from_millis(20);
const ACCEPT_POLL_INTERVAL: Duration = Duration::from_millis(50);
// Callers fan out: a group delay test issues one request per proxy at once, so
// a full queue means the writer is behind, not that the message is surplus.
// Wait it out, but never past this deadline, so a wedged peer still surfaces.
const SEND_QUEUE_TIMEOUT: Duration = Duration::from_secs(5);
const SEND_RETRY_INTERVAL: Duration = Duration::from_millis(2);
const MIN_WRITE_BACKOFF: Duration = Duration::from_millis(1);
#[frb(ignore)]
#[derive(Clone)]
struct MessageSender {
tx: SyncSender<Vec<u8>>,
pending_bytes: Arc<AtomicUsize>,
}
impl MessageSender {
fn channel(capacity: usize) -> (Self, mpsc::Receiver<Vec<u8>>, Arc<AtomicUsize>) {
let (tx, rx) = mpsc::sync_channel::<Vec<u8>>(capacity);
let pending_bytes = Arc::new(AtomicUsize::new(0));
let sender = Self {
tx,
pending_bytes: Arc::clone(&pending_bytes),
};
(sender, rx, pending_bytes)
}
// An empty queue always accepts, even past the budget: a lone frame may be
// larger than the budget, and refusing it would stall forever with nothing
// draining ahead of it.
fn try_reserve(&self, len: usize) -> bool {
self.pending_bytes
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |pending| {
let next = pending.checked_add(len)?;
(pending == 0 || next <= MAX_PENDING_BYTES).then_some(next)
})
.is_ok()
}
fn release(&self, len: usize) {
self.pending_bytes.fetch_sub(len, Ordering::SeqCst);
}
}
fn make_frame(ty: u8, payload: &[u8]) -> Vec<u8> {
let mut frame = Vec::with_capacity(1 + payload.len());
@@ -80,7 +125,7 @@ fn stop_server_thread() -> Result<(), String> {
RUNNING.store(false, Ordering::SeqCst);
let handle = {
let mut state = STATE.lock().map_err(|e| format!("Lock poisoned: {e}"))?;
state.tx = None;
state.sender = None;
state.handle.take()
};
if let Some(handle) = handle {
@@ -125,17 +170,46 @@ pub fn stop_ipc_server() -> Result<(), String> {
#[frb]
pub fn send_ipc_message(data: Vec<u8>) -> Result<(), String> {
validate_frame_len(data.len()).map_err(|e| e.to_string())?;
let tx = STATE
let sender = STATE
.lock()
.map_err(|e| format!("Lock poisoned: {e}"))?
.tx
.sender
.clone()
.ok_or("IPC client is not connected")?;
match tx.try_send(data) {
Ok(()) => Ok(()),
Err(TrySendError::Full(_)) => Err("IPC send queue is full".into()),
Err(TrySendError::Disconnected(_)) => Err("IPC client is disconnected".into()),
enqueue_message(&sender, data, SEND_QUEUE_TIMEOUT, &RUNNING)
}
fn enqueue_message(
sender: &MessageSender,
data: Vec<u8>,
timeout: Duration,
running: &AtomicBool,
) -> Result<(), String> {
let deadline = Instant::now() + timeout;
let len = data.len();
let mut pending = data;
loop {
if sender.try_reserve(len) {
match sender.tx.try_send(pending) {
Ok(()) => return Ok(()),
Err(TrySendError::Disconnected(_)) => {
sender.release(len);
return Err("IPC client is disconnected".into());
}
Err(TrySendError::Full(rejected)) => {
sender.release(len);
pending = rejected;
}
}
}
if !running.load(Ordering::SeqCst) {
return Err("IPC server is stopped".into());
}
if Instant::now() >= deadline {
return Err("IPC send queue is full".into());
}
thread::sleep(SEND_RETRY_INTERVAL);
}
}
@@ -163,6 +237,7 @@ fn write_all_interruptible(
mut data: &[u8],
connection_running: &AtomicBool,
) -> io::Result<()> {
let mut backoff = MIN_WRITE_BACKOFF;
while !data.is_empty() {
if !connection_running.load(Ordering::SeqCst) || !server_active() {
return Err(io::Error::new(
@@ -175,10 +250,18 @@ fn write_all_interruptible(
let result = normalize_windows_pipe_write(result);
match result {
Ok(0) => return Err(io::ErrorKind::WriteZero.into()),
Ok(written) => data = &data[written..],
Ok(written) => {
data = &data[written..];
backoff = MIN_WRITE_BACKOFF;
}
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
thread::sleep(IO_POLL_INTERVAL);
// A Windows PIPE_NOWAIT handle reports a full buffer through a
// zero-byte write, and every frame costs two writes. A flat
// 20ms wait there parks the whole queue behind one frame, so
// back off from 1ms instead.
thread::sleep(backoff);
backoff = (backoff * 2).min(IO_POLL_INTERVAL);
}
Err(e) => return Err(e),
}
@@ -306,7 +389,7 @@ fn is_expected_disconnect_error(error: &io::Error) -> bool {
fn finish_server(name: &str) {
RUNNING.store(false, Ordering::SeqCst);
if let Ok(mut state) = STATE.lock() {
state.tx = None;
state.sender = None;
}
if let Err(e) = cleanup_socket(name) {
ipc_debug!("[IPC] cleanup socket failed: {e}");
@@ -381,9 +464,9 @@ fn io_loop(name: String, sink: StreamSink<Vec<u8>, SseCodec>) {
#[cfg(not(windows))]
let connected_payload = Vec::new();
let (tx, rx) = mpsc::sync_channel::<Vec<u8>>(MAX_PENDING_MESSAGES);
let (sender, rx, pending_bytes) = MessageSender::channel(MAX_PENDING_MESSAGES);
match STATE.lock() {
Ok(mut state) if server_active() => state.tx = Some(tx),
Ok(mut state) if server_active() => state.sender = Some(sender),
Ok(_) => break,
Err(e) => {
report_error(&sink, format!("state lock error: {e}"));
@@ -406,6 +489,7 @@ fn io_loop(name: String, sink: StreamSink<Vec<u8>, SseCodec>) {
while writer_running.load(Ordering::SeqCst) && server_active() {
match rx.recv_timeout(IO_POLL_INTERVAL) {
Ok(data) => {
pending_bytes.fetch_sub(data.len(), Ordering::SeqCst);
if let Err(e) = write_frame(&mut sender, &data, &writer_running) {
if e.kind() != io::ErrorKind::Interrupted {
error = Some(format!("write error: {e}"));
@@ -449,7 +533,7 @@ fn io_loop(name: String, sink: StreamSink<Vec<u8>, SseCodec>) {
connection_running.store(false, Ordering::SeqCst);
if let Ok(mut state) = STATE.lock() {
state.tx = None;
state.sender = None;
}
if let Ok(Some(message)) = writer.join() {
report_error(&sink, message);
@@ -559,6 +643,169 @@ mod tests {
);
}
#[test]
fn enqueue_message_waits_for_a_draining_writer() {
let (sender, rx, _) = MessageSender::channel(1);
enqueue_message(
&sender,
b"first".to_vec(),
Duration::from_secs(5),
&AtomicBool::new(true),
)
.unwrap();
let writer = thread::spawn(move || {
thread::sleep(Duration::from_millis(30));
(rx.recv().unwrap(), rx.recv().unwrap())
});
enqueue_message(
&sender,
b"second".to_vec(),
Duration::from_secs(5),
&AtomicBool::new(true),
)
.unwrap();
assert_eq!(
writer.join().unwrap(),
(b"first".to_vec(), b"second".to_vec())
);
}
#[test]
fn enqueue_message_gives_up_on_a_writer_that_never_drains() {
let (sender, _rx, _) = MessageSender::channel(1);
sender.tx.send(b"first".to_vec()).unwrap();
assert_eq!(
enqueue_message(
&sender,
b"second".to_vec(),
Duration::from_millis(20),
&AtomicBool::new(true),
)
.unwrap_err(),
"IPC send queue is full"
);
}
#[test]
fn enqueue_message_stops_waiting_once_the_server_is_stopped() {
let (sender, _rx, _) = MessageSender::channel(1);
sender.tx.send(b"first".to_vec()).unwrap();
let running = Arc::new(AtomicBool::new(true));
let stopper = Arc::clone(&running);
thread::spawn(move || {
thread::sleep(Duration::from_millis(20));
stopper.store(false, Ordering::SeqCst);
});
let started = Instant::now();
let error = enqueue_message(
&sender,
b"second".to_vec(),
Duration::from_secs(30),
&running,
)
.unwrap_err();
assert_eq!(error, "IPC server is stopped");
assert!(started.elapsed() < Duration::from_secs(5));
}
#[test]
fn enqueue_message_reports_a_gone_writer_without_waiting() {
let (sender, rx, _) = MessageSender::channel(1);
drop(rx);
assert_eq!(
enqueue_message(
&sender,
b"first".to_vec(),
Duration::from_secs(30),
&AtomicBool::new(true),
)
.unwrap_err(),
"IPC client is disconnected"
);
}
#[test]
fn enqueue_message_caps_the_queue_by_bytes_before_the_slot_count() {
let (sender, _rx, pending_bytes) = MessageSender::channel(MAX_PENDING_MESSAGES);
enqueue_message(
&sender,
vec![0u8; MAX_PENDING_BYTES],
Duration::from_secs(5),
&AtomicBool::new(true),
)
.unwrap();
assert_eq!(pending_bytes.load(Ordering::SeqCst), MAX_PENDING_BYTES);
assert_eq!(
enqueue_message(
&sender,
b"overflow".to_vec(),
Duration::from_millis(20),
&AtomicBool::new(true),
)
.unwrap_err(),
"IPC send queue is full"
);
assert_eq!(pending_bytes.load(Ordering::SeqCst), MAX_PENDING_BYTES);
}
#[test]
fn enqueue_message_admits_a_lone_frame_larger_than_the_budget() {
let (sender, rx, pending_bytes) = MessageSender::channel(MAX_PENDING_MESSAGES);
let len = MAX_PENDING_BYTES + 1;
enqueue_message(
&sender,
vec![0u8; len],
Duration::from_millis(20),
&AtomicBool::new(true),
)
.unwrap();
assert_eq!(pending_bytes.load(Ordering::SeqCst), len);
assert_eq!(rx.recv().unwrap().len(), len);
}
#[test]
fn draining_a_message_frees_its_share_of_the_budget() {
let (sender, rx, pending_bytes) = MessageSender::channel(MAX_PENDING_MESSAGES);
enqueue_message(
&sender,
vec![0u8; MAX_PENDING_BYTES],
Duration::from_secs(5),
&AtomicBool::new(true),
)
.unwrap();
let drained = rx.recv().unwrap();
pending_bytes.fetch_sub(drained.len(), Ordering::SeqCst);
assert_eq!(pending_bytes.load(Ordering::SeqCst), 0);
enqueue_message(
&sender,
b"next".to_vec(),
Duration::from_millis(20),
&AtomicBool::new(true),
)
.unwrap();
}
#[test]
fn write_backoff_starts_short_and_is_capped() {
let mut backoff = MIN_WRITE_BACKOFF;
assert_eq!(backoff, Duration::from_millis(1));
for _ in 0..10 {
backoff = (backoff * 2).min(IO_POLL_INTERVAL);
}
assert_eq!(backoff, IO_POLL_INTERVAL);
}
#[test]
fn expected_disconnect_errors_are_not_reported() {
for kind in [
+1 -1
View File
@@ -1,7 +1,7 @@
name: fl_clash
description: A multi-platform proxy client based on ClashMeta, simple and easy to use, open-source and ad-free.
publish_to: 'none'
version: 0.8.95+2026081401
version: 0.8.96+2026081701
environment:
sdk: '>=3.8.0 <4.0.0'
+123
View File
@@ -118,6 +118,129 @@ void main() {
await transport.close();
});
test('waiting for a connection never outlives the request timeout', () async {
final transport = FakeDesktopCoreTransport();
final client = CoreRpcClient(transport);
final stopwatch = Stopwatch()..start();
final result = await client.invoke<bool>(
method: CoreMethod.asyncTestDelay,
timeout: const Duration(milliseconds: 150),
);
expect(result, isNull);
expect(stopwatch.elapsed, lessThan(const Duration(seconds: 5)));
expect(transport.sentMessages, isEmpty);
expect(client.pendingCount, 0);
await client.close();
await transport.close();
});
test('a silent link times out the pending request', () async {
final transport = FakeDesktopCoreTransport.connected();
final client = CoreRpcClient(transport);
final result = await client.invoke<bool>(
method: CoreMethod.getIsInit,
timeout: const Duration(milliseconds: 150),
);
expect(result, isNull);
expect(client.pendingCount, 0);
await client.close();
await transport.close();
});
test('a pending request survives while Core answers its siblings', () async {
final transport = FakeDesktopCoreTransport.connected();
final client = CoreRpcClient(transport);
const timeout = Duration(milliseconds: 300);
final slow = client.invoke<bool>(
method: CoreMethod.asyncTestDelay,
timeout: timeout,
);
final sibling = client.invoke<bool>(
method: CoreMethod.asyncTestDelay,
timeout: timeout,
);
await pumpEventQueue();
final requests = transport.sentMessages
.map((message) => jsonDecode(message) as Map<String, Object?>)
.toList();
expect(requests, hasLength(2));
await Future<void>.delayed(const Duration(milliseconds: 200));
transport.addJson({'id': requests[1]['id'], 'result': true});
expect(await sibling, isTrue);
// Past the original window, but the link proved itself 200ms in.
await Future<void>.delayed(const Duration(milliseconds: 200));
expect(client.pendingCount, 1);
transport.addJson({'id': requests[0]['id'], 'result': true});
expect(await slow, isTrue);
await client.close();
await transport.close();
});
test('extension is capped so a wedged request still fails', () async {
final transport = FakeDesktopCoreTransport.connected();
final client = CoreRpcClient(transport);
const timeout = Duration(milliseconds: 200);
final wedged = client.invoke<bool>(
method: CoreMethod.asyncTestDelay,
timeout: timeout,
);
await pumpEventQueue();
final request = jsonDecode(transport.sentMessages.first) as Map;
// Core stays chatty about everything except this request.
final chatter = Timer.periodic(
const Duration(milliseconds: 50),
(_) => transport.addJson({'id': 'other', 'result': true}),
);
addTearDown(chatter.cancel);
expect(await wedged, isNull);
expect(client.pendingCount, 0);
expect(request['id'], isNotNull);
await client.close();
await transport.close();
});
test(
'a long request budget stops extending once the grace is spent',
() async {
const timeout = Duration(milliseconds: 200);
const grace = Duration(milliseconds: 100);
expect(timeout + grace, lessThan(timeout * 3));
final transport = FakeDesktopCoreTransport.connected();
final client = CoreRpcClient(transport, extensionGrace: grace);
final stopwatch = Stopwatch()..start();
final wedged = client.invoke<bool>(
method: CoreMethod.getIsInit,
timeout: timeout,
);
final chatter = Timer.periodic(
const Duration(milliseconds: 30),
(_) => transport.addJson({'id': 'other', 'result': true}),
);
addTearDown(chatter.cancel);
expect(await wedged, isNull);
expect(stopwatch.elapsed, lessThan(const Duration(milliseconds: 500)));
expect(client.pendingCount, 0);
await client.close();
await transport.close();
},
);
test('malformed data does not terminate response processing', () async {
final transport = FakeDesktopCoreTransport.connected();
final client = CoreRpcClient(transport);
+70
View File
@@ -8,9 +8,14 @@ void main() {
const channel = MethodChannel('$packageName/app');
setUp(() {
App().clearPackageIconCache();
});
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, null);
App().clearPackageIconCache();
});
test('reads previous execution crash state from Android', () async {
@@ -35,6 +40,71 @@ void main() {
expect(await App().didCrashOnPreviousExecution(), isFalse);
});
test('requests every package icon from Android only once', () async {
var iconCallCount = 0;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
iconCallCount++;
return '/icons/${call.arguments['packageName']}.png';
});
final app = App();
final results = await Future.wait([
app.getPackageIcon('com.a'),
app.getPackageIcon('com.a'),
]);
final cached = await app.getPackageIcon('com.a');
expect(iconCallCount, 1);
expect(results.first, isNotNull);
expect(cached, same(results.first));
expect(app.hasPackageIcon('com.a'), isTrue);
expect(app.getCachedPackageIcon('com.a'), same(results.first));
await app.getPackageIcon('com.b');
expect(iconCallCount, 2);
});
test(
'caches packages without an icon and skips empty package names',
() async {
var iconCallCount = 0;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (_) async {
iconCallCount++;
return null;
});
final app = App();
expect(await app.getPackageIcon(''), isNull);
expect(iconCallCount, 0);
expect(app.hasPackageIcon(''), isFalse);
expect(await app.getPackageIcon('com.a'), isNull);
expect(await app.getPackageIcon('com.a'), isNull);
expect(iconCallCount, 1);
expect(app.hasPackageIcon('com.a'), isTrue);
},
);
test('caches a failed package icon lookup', () async {
var iconCallCount = 0;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (_) async {
iconCallCount++;
throw PlatformException(code: 'unavailable');
});
final app = App();
expect(await app.getPackageIcon('com.a'), isNull);
expect(await app.getPackageIcon('com.a'), isNull);
expect(iconCallCount, 1);
});
test('uses false when crash detection is unavailable', () async {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (_) async {
+59
View File
@@ -1,4 +1,5 @@
import 'package:fl_clash/widgets/builder.dart';
import 'package:fl_clash/widgets/inherited.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -23,4 +24,62 @@ void main() {
await tester.pump(const Duration(seconds: 1));
expect(find.text('tick: 2'), findsOneWidget);
});
testWidgets('TickBuilder ticks only while the page is active', (
tester,
) async {
Widget buildApp({required bool isPageActive}) {
return MaterialApp(
home: PageActivityScope(
isActive: isPageActive,
child: TickBuilder(
duration: const Duration(seconds: 1),
builder: (_, tick) => Text('tick: $tick'),
),
),
);
}
await tester.pumpWidget(buildApp(isPageActive: false));
await tester.pump(const Duration(seconds: 3));
expect(find.text('tick: 0'), findsOneWidget);
await tester.pumpWidget(buildApp(isPageActive: true));
await tester.pump(const Duration(seconds: 1));
expect(find.text('tick: 1'), findsOneWidget);
await tester.pumpWidget(buildApp(isPageActive: false));
await tester.pump(const Duration(seconds: 3));
expect(find.text('tick: 1'), findsOneWidget);
});
testWidgets('TickBuilder stops ticking while the app is paused', (
tester,
) async {
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await tester.pumpWidget(
MaterialApp(
home: TickBuilder(
duration: const Duration(seconds: 1),
builder: (_, tick) => Text('tick: $tick'),
),
),
);
await tester.pump(const Duration(seconds: 1));
expect(find.text('tick: 1'), findsOneWidget);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
await tester.pump(const Duration(seconds: 3));
expect(find.text('tick: 1'), findsOneWidget);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await tester.pump(const Duration(seconds: 1));
expect(find.text('tick: 2'), findsOneWidget);
});
}
+211
View File
@@ -0,0 +1,211 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/common/theme.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/views/connection/connections.dart';
import 'package:fl_clash/views/connection/item.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
late ProviderContainer container;
setUp(() {
container = ProviderContainer();
globalState.container = container;
});
tearDown(() {
container.dispose();
});
List<TrackerInfo> buildConnections(int count) {
return List.generate(
count,
(index) => TrackerInfo(
id: '$index',
start: DateTime(2024),
metadata: Metadata(
network: 'tcp',
host: 'host-$index.com',
destinationPort: '443',
),
chains: const ['proxy-a'],
rule: 'MATCH',
rulePayload: '',
),
);
}
Future<void> pumpConnections(
WidgetTester tester, {
required Future<List<TrackerInfo>> Function() connectionsReader,
bool isPageActive = true,
}) async {
tester.view.physicalSize = const Size(600, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: _TestApp(
child: PageActivityScope(
isActive: isPageActive,
child: ConnectionsView(connectionsReader: connectionsReader),
),
),
),
);
await tester.pump();
}
testWidgets('ConnectionsView lazily builds every connection', (tester) async {
final connections = buildConnections(100);
await pumpConnections(tester, connectionsReader: () async => connections);
await tester.pump();
final builtItems = find.byType(TrackerInfoItem).evaluate().length;
expect(builtItems, greaterThan(0));
expect(builtItems, lessThan(connections.length));
expect(find.text('tcp://host-0.com:443'), findsOneWidget);
await tester.scrollUntilVisible(
find.text('tcp://host-99.com:443'),
800,
scrollable: find.byWidgetPredicate(
(widget) =>
widget is Scrollable &&
widget.axisDirection == AxisDirection.down &&
widget.controller != null,
),
);
expect(find.text('tcp://host-99.com:443'), findsOneWidget);
expect(tester.takeException(), null);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('ConnectionsView polls only while the page is active', (
tester,
) async {
var readCount = 0;
Future<List<TrackerInfo>> readConnections() async {
readCount++;
return const [];
}
await pumpConnections(
tester,
connectionsReader: readConnections,
isPageActive: false,
);
await tester.pump(const Duration(seconds: 3));
expect(readCount, 0);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: _TestApp(
child: PageActivityScope(
isActive: true,
child: ConnectionsView(connectionsReader: readConnections),
),
),
),
);
await tester.pump();
expect(readCount, 1);
await tester.pump(const Duration(seconds: 1));
await tester.pump();
expect(readCount, 2);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: _TestApp(
child: PageActivityScope(
isActive: false,
child: ConnectionsView(connectionsReader: readConnections),
),
),
),
);
await tester.pump(const Duration(seconds: 3));
expect(readCount, 2);
expect(tester.takeException(), null);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('ConnectionsView stops polling while the app is paused', (
tester,
) async {
var readCount = 0;
Future<List<TrackerInfo>> readConnections() async {
readCount++;
return const [];
}
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await pumpConnections(tester, connectionsReader: readConnections);
await tester.pump();
expect(readCount, 1);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
await tester.pump(const Duration(seconds: 4));
expect(readCount, 1);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await tester.pump();
expect(readCount, 2);
expect(tester.takeException(), null);
await tester.pumpWidget(const SizedBox.shrink());
});
}
class _TestApp extends StatelessWidget {
final Widget child;
const _TestApp({required this.child});
@override
Widget build(BuildContext context) {
return MaterialApp(
navigatorKey: globalState.navigatorKey,
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
supportedLocales: AppLocalizations.delegate.supportedLocales,
builder: (context, child) {
globalState.measure = Measure.of(context, 1);
globalState.theme = CommonTheme.of(context, 1);
return child!;
},
home: child,
);
}
}
+73
View File
@@ -5,6 +5,7 @@ import 'package:fl_clash/common/theme.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/views/dashboard/widgets/memory_info.dart';
import 'package:fl_clash/widgets/inherited.dart';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -86,6 +87,78 @@ void main() {
await tester.pump();
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('MemoryInfo keeps polling after a failed read', (tester) async {
var readCount = 0;
Future<num> readMemory() async {
readCount++;
if (readCount == 1) {
throw StateError('core unavailable');
}
return readCount;
}
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await tester.pumpWidget(
_TestApp(child: MemoryInfo(memoryReader: readMemory)),
);
await tester.pump();
expect(readCount, 1);
expect(tester.takeException(), null);
await tester.pump(const Duration(seconds: 2));
await tester.pump();
expect(readCount, 2);
expect(tester.takeException(), null);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('MemoryInfo refreshes only while the page is active', (
tester,
) async {
var readCount = 0;
Future<num> readMemory() async {
readCount++;
return readCount;
}
Widget buildApp({required bool isPageActive}) {
return _TestApp(
child: PageActivityScope(
isActive: isPageActive,
child: MemoryInfo(memoryReader: readMemory),
),
);
}
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await tester.pumpWidget(buildApp(isPageActive: false));
await tester.pump(const Duration(seconds: 4));
expect(readCount, 0);
await tester.pumpWidget(buildApp(isPageActive: true));
await tester.pump();
expect(readCount, 1);
await tester.pump(const Duration(seconds: 2));
await tester.pump();
expect(readCount, 2);
await tester.pumpWidget(buildApp(isPageActive: false));
await tester.pump(const Duration(seconds: 4));
expect(readCount, 2);
await tester.pumpWidget(const SizedBox.shrink());
});
}
class _TestApp extends StatelessWidget {