feat(core): bind the Android Core with gomobile

Changelog: Share link import and mrs rule sets
This commit is contained in:
chen08209
2026-10-08 16:09:04 +08:00
parent 6c661f662e
commit 2fa2f7f816
104 changed files with 5537 additions and 3077 deletions
+34
View File
@@ -42,4 +42,38 @@ void main() {
await expectLater(safeDeletePath(join(root.path, 'missing')), completes);
});
});
group('writeAsStringAtomically', () {
test('replaces the file and leaves nothing beside it', () async {
final file = File(join(root.path, 'config.yaml'))
..writeAsStringSync('mixed-port: 7890');
await file.writeAsStringAtomically('mixed-port: 7891');
expect(file.readAsStringSync(), 'mixed-port: 7891');
expect(root.listSync().map((entry) => basename(entry.path)), [
'config.yaml',
]);
});
test('creates the directory a first write needs', () async {
final file = File(join(root.path, 'home', 'config.yaml'));
await file.writeAsStringAtomically('mode: rule');
expect(file.readAsStringSync(), 'mode: rule');
});
test('keeps the old file when the new one cannot be written', () async {
final file = File(join(root.path, 'config.yaml'))
..writeAsStringSync('mode: rule');
Directory('${file.path}.$pid.tmp').createSync();
await expectLater(
file.writeAsStringAtomically('mode: global'),
throwsA(isA<FileSystemException>()),
);
expect(file.readAsStringSync(), 'mode: rule');
});
});
}
+145
View File
@@ -0,0 +1,145 @@
import 'package:fl_clash/common/yaml_writer.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:yaml/yaml.dart';
const _awkwardStrings = [
'0123', '00', '1e3', '0x1F', '0o17', '0b101', '1_000', '+1', '-0', '.5', //
'1.', '.inf', '-.Inf', '.nan', 'yes', 'No', 'on', 'OFF', 'y', 'n', 'true',
'False', 'null', 'Null', '~', '', ' ', '2001-12-14', '12:30', '1:20:30',
'=', '!a', '- a', '? a', ': a', 'a: b', 'a #b', '#a', '&a', '*a', '|', '>',
'%a', '@a', '`a', '[a', '{a', ',a', "'a", '"a', ' lead', 'trail ', 'a\tb',
'geosite:cn', '+.google.com', '*.lan', 'rule-set:cn,direct', 'HK 🇭🇰 香港',
'p\x00w\x7f\r\u0085\u2028\ufeff\uffff "q" \\', 'G\x07', 'k\x1bey',
'a\nb', 'multi\nline \x1b\n', 'tail\n', 'two\n\n', '\nlead break', ' a\nb',
'a\n \nb', 'a \nb ', 'a\n\tb', '\n', 'a\n#b\n- c\n---\n...',
];
void main() {
test('keeps the layout the Core config has always had', () {
final text = writeYaml({
'mixed-port': 7890,
'proxies': [
{'name': 'HK', 'type': 'ss', 'port': 443, 'udp': true, 'plugin': null},
],
'proxy-groups': [
{
'name': 'G',
'proxies': ['HK', 'DIRECT'],
},
],
'dns': {
'enable': true,
'nameserver': <String>[],
'fallback-filter': <String, Object?>{},
},
'nameserver-policy': {'geosite:cn': 'system', '+.lan': 'system'},
'certificate': 'line 1\nline 2\n',
'rules': ['MATCH,G'],
'matrix': [
[1, 2],
[3],
],
});
expect(text, '''
mixed-port: 7890
proxies:
- name: "HK"
type: "ss"
port: 443
udp: true
plugin: null
proxy-groups:
- name: "G"
proxies:
- "HK"
- "DIRECT"
dns:
enable: true
nameserver: []
fallback-filter: {}
nameserver-policy:
geosite:cn: "system"
+.lan: "system"
certificate: |
line 1
line 2
rules:
- "MATCH,G"
matrix:
- - 1
- 2
- - 3
''');
});
test('reads back every string as the same string, as value or key', () {
final data = {
'values': _awkwardStrings,
'keys': {
for (final text in _awkwardStrings.where((text) => text.isNotEmpty))
text: text,
},
};
expect(loadYaml(writeYaml(data)), data);
});
test('reads back numbers, booleans, nulls and non-string keys', () {
final data = {
'numbers': [0, -1, 1 << 53, 1e21, 1e-7, 3.0, -0.5],
'flags': [true, false, null],
'keys': {1: 'one', 2.5: 'half', true: 'yes', null: 'none'},
};
expect(loadYaml(writeYaml(data)), data);
});
test('writes non-finite doubles as YAML floats', () {
final read =
loadYaml(
writeYaml([double.nan, double.infinity, double.negativeInfinity]),
)
as List;
expect((read[0] as double).isNaN, isTrue);
expect(read.sublist(1), [double.infinity, double.negativeInfinity]);
});
test('replaces a lone surrogate, which UTF-8 cannot carry', () {
expect(loadYaml(writeYaml({'name': 'a\ud800b\udc00c'})), {
'name': 'a\ufffdb\ufffdc',
});
expect(writeYaml({'flag': '🇭🇰'}), 'flag: "🇭🇰"\n');
});
test('leaves a merge key plain, the way the profile wrote it', () {
final text = writeYaml({
'proxy': {
'<<': {'type': 'ss'},
'name': 'HK',
},
});
expect(text, contains(' <<:\n type: "ss"\n'));
});
test('encodes sets and objects through toJson', () {
expect(
loadYaml(
writeYaml({
'set': {'a', 'b'},
'object': _Encodable(),
}),
),
{
'set': ['a', 'b'],
'object': {'kind': 'encodable'},
},
);
});
}
class _Encodable {
Map<String, Object?> toJson() => {'kind': 'encodable'};
}
+47
View File
@@ -1,5 +1,7 @@
import 'dart:async';
import 'dart:io';
import 'package:fl_clash/common/exception.dart';
import 'package:fl_clash/core/controller.dart';
import 'package:fl_clash/core/desktop/model.dart';
import 'package:fl_clash/core/interface.dart';
@@ -108,6 +110,51 @@ void main() {
verify(() => mock.validateConfig('/path')).called(1);
});
group('validateProfile', () {
late Directory dir;
late String path;
setUp(() {
dir = Directory.systemTemp.createTempSync('validate_profile_test');
path = '${dir.path}/profile.yaml';
when(() => mock.validateConfig(path)).thenAnswer((_) async => '');
});
tearDown(() => dir.deleteSync(recursive: true));
test('decrypts an age-encrypted config in place', () async {
File(
path,
).writeAsStringSync('-----BEGIN AGE ENCRYPTED FILE-----\nYWdl\n');
when(
() => mock.decryptConfig(path, 'AGE-SECRET-KEY-1A'),
).thenAnswer((_) async => 'proxies: []');
expect(await controller.validateProfile(path, 'AGE-SECRET-KEY-1A'), '');
expect(File(path).readAsStringSync(), 'proxies: []');
});
test('asks for a key an encrypted config lacks', () async {
File(
path,
).writeAsStringSync('-----BEGIN AGE ENCRYPTED FILE-----\nYWdl\n');
await expectLater(
controller.validateProfile(path),
throwsA(isA<AgeSecretKeyRequiredException>()),
);
verifyNever(() => mock.decryptConfig(any(), any()));
});
test('leaves a plain config as it is', () async {
File(path).writeAsStringSync('proxies: []');
expect(await controller.validateProfile(path, 'AGE-SECRET-KEY-1A'), '');
verifyNever(() => mock.decryptConfig(any(), any()));
expect(File(path).readAsStringSync(), 'proxies: []');
});
});
test('updateConfig delegates to interface', () async {
const params = UpdateParams(
tun: Tun(enable: false),
+20
View File
@@ -8,12 +8,18 @@ class _RecordingListener with CoreEventListener {
final void Function()? onLoadedCallback;
final List<String> loaded = [];
final List<String> dialerLoops = [];
@override
void onLoaded(String providerName) {
loaded.add(providerName);
onLoadedCallback?.call();
}
@override
void onDialerLoop(String proxyName) {
dialerLoops.add(proxyName);
}
}
void main() {
@@ -50,4 +56,18 @@ void main() {
expect(second.loaded, ['provider-a', 'provider-b']);
},
);
test('a dialer loop the core stopped reaches the listeners', () async {
final listener = _RecordingListener();
coreEventManager.addListener(listener);
addTearDown(() => coreEventManager.removeListener(listener));
coreEventsFromData({
'type': 'dialerLoop',
'data': 'Front',
}).forEach(coreEventManager.sendEvent);
await pumpEventQueue();
expect(listener.dialerLoops, ['Front']);
});
}
+29
View File
@@ -119,6 +119,35 @@ void main() {
expect(await core.getExternalProvider('p'), isNull);
expect(await core.watchRoute(true), isNull);
});
test('calls nobody waits on absorb a Core that is gone', () async {
final core = _DisconnectedCore();
await core.resetTraffic();
await core.startLog();
await core.stopLog();
expect(core.invoked, [
CoreMethod.resetTraffic,
CoreMethod.startLog,
CoreMethod.stopLog,
]);
});
}
class _DisconnectedCore extends _SilentCore {
@override
Future<T?> invokeMethod<T>({
required CoreMethod method,
Object? arguments,
Duration? timeout,
}) async {
invoked.add(method);
throw const CoreMethodException(
code: 'transport_disconnected',
message: 'Core RPC client is closed',
);
}
}
class _AnsweringCore extends _SilentCore {
+43
View File
@@ -94,7 +94,9 @@ class _RecordingCoreHandler extends CoreHandlerInterface {
'runtimeOther': 32,
},
CoreMethod.changeProxy => {'message': '', 'changed': true},
CoreMethod.compileRuleSet => {'behavior': 'ipcidr', 'format': 'text'},
CoreMethod.getConnectionCount => 3,
CoreMethod.validateFilters => ['', 'missing closing )'],
CoreMethod.watchRoute => {
'core-epoch': 1,
'picks-version': 0,
@@ -210,6 +212,15 @@ void main() {
expect(handler.calls[CoreMethod.clearEffect], 42);
});
test('validateFilters sends the filters and reads one error each', () async {
final handler = _RecordingCoreHandler();
final errors = await handler.validateFilters(['hk`jp', '(hk']);
expect(handler.calls[CoreMethod.validateFilters], ['hk`jp', '(hk']);
expect(errors, ['', 'missing closing )']);
});
test('dumpRuleSet sends the rule set path', () async {
final handler = _RecordingCoreHandler();
@@ -218,6 +229,38 @@ void main() {
expect(handler.calls[CoreMethod.dumpRuleSet], '/rules.mrs');
});
test('decryptConfig sends the path and the age key', () async {
final handler = _RecordingCoreHandler();
await handler.decryptConfig('/profile.yaml', 'AGE-SECRET-KEY-1A');
expect(handler.calls[CoreMethod.decryptConfig], {
'path': '/profile.yaml',
'age-secret-key': 'AGE-SECRET-KEY-1A',
});
});
test(
'compileRuleSet names a cached file and reads what the Core found',
() async {
final handler = _RecordingCoreHandler();
final info = await handler.compileRuleSet(
'abc',
url: 'https://example.com/r.list',
);
expect(handler.calls[CoreMethod.compileRuleSet], {
'name': 'abc',
'url': 'https://example.com/r.list',
});
expect(info, (
behavior: RuleProviderBehavior.ipcidr,
format: RuleProviderFormat.text,
));
},
);
test('event contract accepts batches and legacy single events', () async {
final fixture =
json.decode(
+102 -4
View File
@@ -9,6 +9,7 @@ import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/manager/core_manager.dart';
import 'package:fl_clash/manager/status_manager.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/action.dart';
import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/providers/config.dart';
import 'package:fl_clash/providers/core.dart';
@@ -45,14 +46,13 @@ class _FakePathProvider extends PathProviderPlatform {
const _nullProfileSetupState = SetupState(
profileId: null,
profileLastUpdateDate: null,
overwriteType: OverwriteType.standard,
profileType: ProfileType.file,
extendType: ExtendType.standard,
rules: [],
proxyGroups: [],
addedRules: [],
script: null,
dnsOverrideKeys: {},
overrideDns: false,
dns: Dns(),
overrides: ProfileOverrides(),
);
const _crash = CoreEvent(type: CoreEventType.crash, data: 'boom');
@@ -73,6 +73,21 @@ CoreEvent _geoUpdate({
);
}
class _ApplyRecordingSetupAction extends SetupAction {
int updates = 0;
int applies = 0;
@override
Future<void> updateConfigDebounce() async {
updates++;
}
@override
void applyProfileDebounce({bool silence = false, bool force = false}) {
applies++;
}
}
_MockCoreHandlerInterface _coreInterface() {
final coreInterface = _MockCoreHandlerInterface();
when(() => coreInterface.startLog()).thenAnswer((_) {});
@@ -241,6 +256,8 @@ void main() {
testWidgets('the log stream follows the openLogs setting', (tester) async {
final coreInterface = _coreInterface();
final container = await _pumpCoreManager(tester, coreInterface);
container.read(coreStatusProvider.notifier).value = CoreStatus.connected;
await tester.pump();
verify(() => coreInterface.stopLog()).called(1);
verifyNever(() => coreInterface.startLog());
@@ -262,6 +279,87 @@ void main() {
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('the log stream waits for the core and is restored on '
'every connection', (tester) async {
final coreInterface = _coreInterface();
final container = await _pumpCoreManager(tester, coreInterface);
container
.read(appSettingProvider.notifier)
.update((state) => state.copyWith(openLogs: true));
await tester.pump();
verifyNever(() => coreInterface.startLog());
verifyNever(() => coreInterface.stopLog());
container.read(coreStatusProvider.notifier).value = CoreStatus.connected;
await tester.pump();
verify(() => coreInterface.startLog()).called(1);
container.read(coreStatusProvider.notifier).value = CoreStatus.disconnected;
await tester.pump();
container.read(coreStatusProvider.notifier).value = CoreStatus.connecting;
await tester.pump();
verifyNever(() => coreInterface.startLog());
container.read(coreStatusProvider.notifier).value = CoreStatus.connected;
await tester.pump();
verify(() => coreInterface.startLog()).called(1);
verifyNever(() => coreInterface.stopLog());
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('a setting the Core patches live is not re-applied', (
tester,
) async {
final setupAction = _ApplyRecordingSetupAction();
final container = await _pumpCoreManager(
tester,
_coreInterface(),
overrides: [setupActionProvider.overrideWith(() => setupAction)],
);
container
.read(patchClashConfigProvider.notifier)
.update((state) => state.copyWith(mode: Mode.global, mixedPort: 8899));
await tester.pump();
expect(setupAction.updates, 1);
expect(setupAction.applies, 0);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('a setting only a setup carries re-applies the profile', (
tester,
) async {
final setupAction = _ApplyRecordingSetupAction();
final container = await _pumpCoreManager(
tester,
_coreInterface(),
overrides: [setupActionProvider.overrideWith(() => setupAction)],
);
container
.read(patchClashConfigProvider.notifier)
.update((state) => state.copyWith(hosts: const {'a.test': '1.1.1.1'}));
await tester.pump();
container
.read(networkSettingProvider.notifier)
.update(
(state) => state.copyWith(appendSystemDns: !state.appendSystemDns),
);
await tester.pump();
expect(setupAction.applies, 2);
expect(setupAction.updates, 0);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('core logs are recorded for the logs view', (tester) async {
final coreInterface = _coreInterface();
final container = await _pumpCoreManager(tester, coreInterface);
+97
View File
@@ -10,10 +10,12 @@ void main() {
final json = {
'selected-map': {'G1': 'P1'},
'test-url': 'http://test.com',
'skip-cert-verify': true,
};
final params = SetupParams.fromJson(json);
expect(params.selectedMap, {'G1': 'P1'});
expect(params.testUrl, 'http://test.com');
expect(params.skipCertVerify, isTrue);
});
test('toJson uses snake-case keys', () {
@@ -24,6 +26,7 @@ void main() {
final json = params.toJson();
expect(json['selected-map'], {'G1': 'P1'});
expect(json['test-url'], 'http://t.com');
expect(json['skip-cert-verify'], isFalse);
});
});
@@ -61,6 +64,100 @@ void main() {
});
});
group('VpnOptions.effective', () {
const options = VpnOptions(
enable: true,
port: 7890,
ipv6: false,
dnsHijacking: false,
accessControlProps: AccessControlProps(
enable: true,
mode: AccessControlMode.acceptSelected,
acceptList: ['a.app'],
rejectList: ['b.app'],
),
allowBypass: true,
systemProxy: true,
bypassDomain: ['localhost'],
stack: 'mixed',
routeAddress: ['0.0.0.0/0'],
mtu: 9000,
);
test('tells apart every option the tunnel is built from', () {
final changes = [
options.copyWith(port: 7891),
options.copyWith(ipv6: true),
options.copyWith(dnsHijacking: true),
options.copyWith(allowBypass: false),
options.copyWith(systemProxy: false),
options.copyWith(bypassDomain: const ['example.com']),
options.copyWith(stack: 'gvisor'),
options.copyWith(routeAddress: const ['10.0.0.0/8']),
options.copyWith(mtu: 1500),
options.copyWith(congestionController: 'bbr'),
options.copyWith(
accessControlProps: options.accessControlProps.copyWith(
acceptList: const ['c.app'],
),
),
options.copyWith(enable: false),
];
for (final changed in changes) {
expect(changed.effective, isNot(options.effective));
}
});
test('ignores how the app list is shown and the list not applied', () {
final changed = options.copyWith(
accessControlProps: options.accessControlProps.copyWith(
rejectList: const ['c.app'],
sort: AccessSortType.name,
isFilterSystemApp: false,
),
);
expect(changed.effective, options.effective);
});
test('ignores the proxy port and bypass list without a system proxy', () {
final withoutProxy = options.copyWith(systemProxy: false);
expect(
withoutProxy.copyWith(port: 7891, bypassDomain: const []).effective,
withoutProxy.effective,
);
});
test('ignores the access lists while access control is off', () {
final open = options.copyWith(
accessControlProps: options.accessControlProps.copyWith(enable: false),
);
expect(
open
.copyWith(
accessControlProps: open.accessControlProps.copyWith(
mode: AccessControlMode.rejectSelected,
acceptList: const [],
),
)
.effective,
open.effective,
);
});
test('a proxy-only run reads none of the tunnel options', () {
final proxyOnly = options.copyWith(enable: false);
expect(
proxyOnly.copyWith(ipv6: true, stack: 'gvisor', mtu: 1500).effective,
proxyOnly.effective,
);
});
});
group('ChangeProxyParams', () {
test('fromJson and toJson use snake-case', () {
final json = {'group-name': 'Proxy', 'proxy-name': 'auto'};