feat(android): add launcher shortcuts and one-step on-demand access

Changelog: Launcher shortcuts and one-step on-demand access
This commit is contained in:
chen08209
2026-10-08 02:49:08 +08:00
parent 94a25f4827
commit d3fa2252b0
53 changed files with 2089 additions and 913 deletions
+32 -1
View File
@@ -2,6 +2,8 @@ import 'dart:convert';
import 'package:fl_clash/manager/android_manager.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/plugins/service.dart';
import 'package:fl_clash/providers/action.dart';
import 'package:fl_clash/providers/config.dart';
import 'package:fl_clash/providers/database.dart';
import 'package:fl_clash/state.dart';
@@ -13,6 +15,16 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/test_app.dart';
import '../helpers/test_profiles.dart';
class _RecordingSetupAction extends SetupAction {
static final requests = <bool>[];
@override
Future<bool> setRunning(bool running, {bool initialize = false}) async {
requests.add(running);
return true;
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
SharedPreferences.setMockInitialValues({});
@@ -23,8 +35,12 @@ void main() {
setUp(() async {
store = await SharedPreferences.getInstance();
await store.clear();
_RecordingSetupAction.requests.clear();
container = ProviderContainer(
overrides: [profilesProvider.overrideWith(TestProfiles.new)],
overrides: [
profilesProvider.overrideWith(TestProfiles.new),
setupActionProvider.overrideWith(_RecordingSetupAction.new),
],
);
globalState.container = container;
});
@@ -52,6 +68,21 @@ void main() {
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('a stop the service reports turns the running state off', (
tester,
) async {
await pumpAndroidManager(tester);
final listener =
tester.state(find.byType(AndroidManager)) as ServiceListener;
listener.onServiceStopped();
await tester.pump();
expect(_RecordingSetupAction.requests, [false]);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('persists shared state after the debounce window', (
tester,
) async {
@@ -4,10 +4,13 @@ import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:fl_clash/manager/connectivity_manager.dart';
import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/providers/config.dart';
import 'package:fl_clash/providers/on_demand.dart';
import 'package:fl_clash/state.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:wifi_ssid/wifi_ssid.dart';
void main() {
late ProviderContainer container;
@@ -126,6 +129,31 @@ void main() {
expect(currentSsid(), 'Home');
});
testWidgets('reads the SSID again once location permission is granted', (
tester,
) async {
const channel = MethodChannel('wifi_ssid');
final messenger = tester.binding.defaultBinaryMessenger;
messenger.setMockMethodCallHandler(channel, (_) async => 0);
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
var permitted = false;
await pumpManager(tester, readSsid: () async => permitted ? 'Home' : null);
connectivity.add([ConnectivityResult.wifi]);
await tester.pumpAndSettle();
expect(currentSsid(), isNull);
permitted = true;
await container.read(locationPermissionsProvider.notifier).request();
await tester.pumpAndSettle();
expect(
container.read(locationPermissionsProvider),
WifiSsidPermission.granted,
);
expect(currentSsid(), 'Home');
});
testWidgets('drops the SSID when the last excluded network is removed', (
tester,
) async {
-22
View File
@@ -99,26 +99,4 @@ void main() {
expect(_RecordingSetupAction.requests, [true]);
});
testWidgets('a tile stop request stops a running core', (tester) async {
final listener = await pumpTileManager(tester);
markStarted();
await tester.pump();
listener.onStop();
await tester.pumpAndSettle();
expect(_RecordingSetupAction.requests, [false]);
});
testWidgets('a tile stop request is ignored when nothing is running', (
tester,
) async {
final listener = await pumpTileManager(tester);
listener.onStop();
await tester.pumpAndSettle();
expect(_RecordingSetupAction.requests, isEmpty);
});
}
+117 -57
View File
@@ -1,8 +1,10 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/manager/status_manager.dart';
import 'package:fl_clash/manager/vpn_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/state.dart';
@@ -11,19 +13,28 @@ import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
class _RestartRecordingSetupAction extends SetupAction {
int restarts = 0;
@override
Future<void> restartVpn() async {
restarts++;
}
}
void main() {
late ProviderContainer container;
late _RestartRecordingSetupAction setupAction;
setUp(() {
container = ProviderContainer();
setupAction = _RestartRecordingSetupAction();
container = ProviderContainer(
overrides: [setupActionProvider.overrideWith(() => setupAction)],
);
globalState.container = container;
globalState.lastVpnState = null;
});
tearDown(() {
container.dispose();
globalState.lastVpnState = null;
});
tearDown(() => container.dispose());
Future<void> pumpVpnManager(WidgetTester tester) async {
await tester.pumpWidget(
@@ -46,73 +57,122 @@ void main() {
await tester.pump();
}
Future<void> outlastTip(WidgetTester tester) async {
await tester.pump(const Duration(seconds: 7));
await tester.pump(const Duration(milliseconds: 500));
}
Future<void> drainTimers(WidgetTester tester) async {
await tester.pump(const Duration(seconds: 11));
await outlastTip(tester);
await tester.pumpWidget(const SizedBox.shrink());
}
testWidgets('shows a tip when the vpn state changes while started', (
void startService() {
container.read(runningVpnOptionsProvider.notifier).value = container.read(
vpnOptionsProvider,
);
}
void updateVpn(VpnProps Function(VpnProps state) update) {
container.read(vpnSettingProvider.notifier).update(update);
}
Finder tip() => find.text(currentAppLocalizations.vpnConfigChangeDetected);
testWidgets('a change the running service reads asks for a restart', (
tester,
) async {
await pumpVpnManager(tester);
container.read(runTimeProvider.notifier).value = 1;
startService();
container
.read(vpnSettingProvider.notifier)
.update((_) => const VpnProps(enable: false));
updateVpn((state) => state.copyWith(ipv6: !state.ipv6));
await tester.pump();
expect(tip(), findsOneWidget);
expect(
find.text(currentAppLocalizations.vpnConfigChangeDetected),
findsOneWidget,
);
await drainTimers(tester);
});
testWidgets('does not show a tip when not started', (tester) async {
await pumpVpnManager(tester);
container
.read(vpnSettingProvider.notifier)
.update((_) => const VpnProps(enable: false));
await tester.pump();
expect(
find.text(currentAppLocalizations.vpnConfigChangeDetected),
findsNothing,
);
await drainTimers(tester);
});
testWidgets('does not repeat the tip for the same vpn state', (tester) async {
await pumpVpnManager(tester);
container.read(runTimeProvider.notifier).value = 1;
container
.read(vpnSettingProvider.notifier)
.update((_) => const VpnProps(enable: false));
await tester.pump();
expect(
find.text(currentAppLocalizations.vpnConfigChangeDetected),
findsOneWidget,
);
await tester.pump(const Duration(seconds: 7));
await tester.pump(const Duration(milliseconds: 500));
expect(
find.text(currentAppLocalizations.vpnConfigChangeDetected),
findsNothing,
);
await tester.tap(find.text(currentAppLocalizations.restart));
await tester.pump();
expect(setupAction.restarts, 1);
await drainTimers(tester);
});
testWidgets('a mixed port change asks for a restart under a system proxy', (
tester,
) async {
await pumpVpnManager(tester);
startService();
globalState.lastVpnState = container.read(vpnStateProvider);
container
.read(vpnSettingProvider.notifier)
.update((_) => const VpnProps(enable: true));
.read(patchClashConfigProvider.notifier)
.update((state) => state.copyWith(mixedPort: state.mixedPort + 1));
await tester.pump();
expect(
find.text(currentAppLocalizations.vpnConfigChangeDetected),
findsNothing,
expect(tip(), findsOneWidget);
await drainTimers(tester);
});
testWidgets('no tip while no service is running', (tester) async {
await pumpVpnManager(tester);
updateVpn((state) => state.copyWith(ipv6: !state.ipv6));
await tester.pump();
expect(tip(), findsNothing);
await drainTimers(tester);
});
testWidgets('no tip for what the service never reads', (tester) async {
await pumpVpnManager(tester);
startService();
updateVpn(
(state) => state.copyWith(
accessControlProps: state.accessControlProps.copyWith(
sort: AccessSortType.name,
isFilterSystemApp: !state.accessControlProps.isFilterSystemApp,
),
),
);
await tester.pump();
expect(tip(), findsNothing);
await drainTimers(tester);
});
testWidgets('changing back to what the service runs asks for nothing', (
tester,
) async {
await pumpVpnManager(tester);
startService();
updateVpn((state) => state.copyWith(ipv6: !state.ipv6));
await tester.pump();
expect(tip(), findsOneWidget);
await outlastTip(tester);
expect(tip(), findsNothing);
updateVpn((state) => state.copyWith(ipv6: !state.ipv6));
await tester.pump();
expect(tip(), findsNothing);
await drainTimers(tester);
});
testWidgets('a restart measures later changes against the new options', (
tester,
) async {
await pumpVpnManager(tester);
startService();
updateVpn((state) => state.copyWith(ipv6: !state.ipv6));
await tester.pump();
await outlastTip(tester);
expect(tip(), findsNothing);
startService();
updateVpn((state) => state.copyWith(ipv6: !state.ipv6));
await tester.pump();
expect(tip(), findsOneWidget);
await drainTimers(tester);
});
}
+34
View File
@@ -1,3 +1,6 @@
import 'dart:async';
import 'dart:math';
import 'package:fl_clash/common/boot_record.dart';
import 'package:fl_clash/common/constant.dart';
import 'package:fl_clash/plugins/app.dart';
@@ -92,6 +95,37 @@ void main() {
},
);
test(
'asks Android for a bounded number of package icons at a time',
() async {
var inFlight = 0;
var peak = 0;
final release = Completer<void>();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
inFlight++;
peak = max(peak, inFlight);
await release.future;
inFlight--;
return '/icons/${call.arguments['packageName']}.png';
});
final app = App();
final icons = Future.wait([
for (var i = 0; i < maxConcurrentIconLoads * 2; i++)
app.getPackageIcon('com.$i'),
]);
await pumpEventQueue();
expect(inFlight, maxConcurrentIconLoads);
release.complete();
expect(await icons, everyElement(isNotNull));
expect(peak, maxConcurrentIconLoads);
},
);
test('caches a failed package icon lookup', () async {
var iconCallCount = 0;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
+91
View File
@@ -15,6 +15,13 @@ class _RecordingListener with ServiceListener {
void onServiceEvent(CoreEvent event) => events.add(event);
}
class _RecordingStopListener with ServiceListener {
var stops = 0;
@override
void onServiceStopped() => stops++;
}
class _ThrowingListener with ServiceListener {
var called = false;
@@ -23,6 +30,11 @@ class _ThrowingListener with ServiceListener {
called = true;
throw StateError('listener boom');
}
@override
void onServiceStopped() {
throw StateError('listener boom');
}
}
class _MutatingListener with ServiceListener {
@@ -146,6 +158,85 @@ void main() {
});
});
group('stop reports', () {
late _RecordingStopListener listener;
Future<void> reportStopped(Object? revision) async {
await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.handlePlatformMessage(
channelName,
codec.encodeMethodCall(MethodCall('stopped', revision)),
null,
);
}
int lastRevision() => calls.last.arguments as int;
setUp(() {
mockChannel((_) async => true);
listener = _RecordingStopListener();
Service().addListener(listener);
});
tearDown(() => Service().removeListener(listener));
test('every command carries a revision newer than the last', () async {
await Service().start();
final first = lastRevision();
await Service().stop();
expect(lastRevision(), greaterThan(first));
});
test(
'a report for the start in force reaches the listeners once',
() async {
await Service().start();
await reportStopped(lastRevision());
await reportStopped(lastRevision());
expect(listener.stops, 1);
},
);
test('a report older than the newest command is dropped', () async {
await Service().start();
final stale = lastRevision();
await Service().stop();
await Service().start();
await reportStopped(stale);
expect(listener.stops, 0);
});
test(
'a report is dropped while Flutter itself asked for the stop',
() async {
await Service().start();
await Service().stop();
await reportStopped(lastRevision());
expect(listener.stops, 0);
},
);
test('a throwing listener does not starve the next one', () async {
final throwing = _ThrowingListener();
Service().removeListener(listener);
Service().addListener(throwing);
Service().addListener(listener);
addTearDown(() => Service().removeListener(throwing));
await Service().start();
await reportStopped(lastRevision());
expect(listener.stops, 1);
});
});
group('getRunTime', () {
test('maps a millisecond stamp to a DateTime', () async {
final stamp = DateTime.utc(2026, 8, 16).millisecondsSinceEpoch;
+1 -5
View File
@@ -9,9 +9,6 @@ class _RecordingListener with TileListener {
@override
void onStart() => calls.add('start');
@override
void onStop() => calls.add('stop');
@override
void onDetached() => calls.add('detached');
}
@@ -58,10 +55,9 @@ void main() {
addTearDown(() => Tile.instance.removeListener(listener));
await emitFromPlatform('start');
await emitFromPlatform('stop');
await emitFromPlatform('detached');
expect(listener.calls, ['start', 'stop', 'detached']);
expect(listener.calls, ['start', 'detached']);
});
test('delivers one action to every registered listener', () async {
+303
View File
@@ -0,0 +1,303 @@
import 'dart:async';
import 'package:fl_clash/common/constant.dart';
import 'package:fl_clash/plugins/battery.dart';
import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/providers/config.dart';
import 'package:fl_clash/providers/on_demand.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:riverpod/riverpod.dart';
import 'package:wifi_ssid/wifi_ssid.dart';
class _AndroidBatteryOptimizationIgnored extends BatteryOptimizationIgnored {
@override
BatteryOptimization? get plugin => BatteryOptimization.instance;
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
group('location permission', () {
const channel = MethodChannel('wifi_ssid');
late ProviderContainer container;
late List<String> calls;
void answer(Future<Object?> Function(MethodCall call) handler) {
messenger.setMockMethodCallHandler(channel, (call) {
calls.add(call.method);
return handler(call);
});
}
ProviderContainer excluding(List<String> ssids) {
return ProviderContainer(
overrides: [excludeSSIDsProvider.overrideWithValue(ssids)],
);
}
LocationPermissions notifier() {
return container.read(locationPermissionsProvider.notifier);
}
setUp(() {
calls = [];
container = excluding(const ['Office Wi-Fi']);
});
tearDown(() {
messenger.setMockMethodCallHandler(channel, null);
container.dispose();
});
test('asks for the permission once a network is excluded', () async {
answer(
(call) async => switch (call.method) {
'checkPermission' => 1,
_ => 0,
},
);
await notifier().refresh();
expect(calls, ['checkPermission', 'requestPermission']);
expect(
container.read(locationPermissionsProvider),
WifiSsidPermission.granted,
);
});
test('a safe mode build neither checks nor requests location', () async {
container.dispose();
container = ProviderContainer(
overrides: [
excludeSSIDsProvider.overrideWithValue(const ['Office Wi-Fi']),
safeModeProvider.overrideWithValue(true),
],
);
answer((_) async => 0);
await notifier().refresh();
expect(calls, isEmpty);
expect(
container.read(locationPermissionsProvider),
WifiSsidPermission.denied,
);
});
test('nothing is asked while no network is excluded', () async {
container.dispose();
container = excluding(const []);
answer((_) async => 1);
await notifier().refresh();
expect(calls, ['checkPermission']);
});
test('auto-requests at most once per session while denied', () async {
answer((_) async => 1);
await notifier().refresh();
await notifier().refresh();
expect(calls.where((call) => call == 'requestPermission').length, 1);
});
test('an empty excluded list re-arms the auto-request', () async {
container.dispose();
container = ProviderContainer();
answer((_) async => 1);
await notifier().refresh();
container.read(excludeSSIDsProvider.notifier).value = const ['Office'];
await notifier().refresh();
await notifier().refresh();
container.read(excludeSSIDsProvider.notifier).value = const [];
await notifier().refresh();
container.read(excludeSSIDsProvider.notifier).value = const ['Office'];
await notifier().refresh();
expect(calls.where((call) => call == 'requestPermission').length, 2);
});
test('a failed auto-request is retried on the next refresh', () async {
answer((call) async {
if (call.method == 'requestPermission') {
throw PlatformException(code: 'IN_PROGRESS');
}
return 1;
});
await expectLater(notifier().refresh(), completes);
await notifier().refresh();
expect(calls.where((call) => call == 'requestPermission').length, 2);
});
test('a check keeps a permanent denial until it sees the grant', () async {
container.dispose();
container = excluding(const []);
var checked = 1;
answer(
(call) async => switch (call.method) {
'requestPermission' => 2,
_ => checked,
},
);
await notifier().request();
await notifier().refresh();
expect(
container.read(locationPermissionsProvider),
WifiSsidPermission.permanentlyDenied,
);
checked = 0;
await notifier().refresh();
expect(
container.read(locationPermissionsProvider),
WifiSsidPermission.granted,
);
});
test('a request made while one is showing answers null', () async {
final prompt = Completer<Object?>();
answer((_) => prompt.future);
final first = notifier().request();
final second = await notifier().request();
prompt.complete(0);
expect(second, isNull);
expect(await first, WifiSsidPermission.granted);
expect(calls, ['requestPermission']);
});
});
group('battery optimization', () {
const methodChannel = MethodChannel('$packageName/battery');
const eventChannel = EventChannel('$packageName/battery/optimization');
late ProviderContainer container;
late List<String> calls;
late MockStreamHandlerEventSink events;
late bool listening;
void answer(Future<Object?> Function(MethodCall call) handler) {
messenger.setMockMethodCallHandler(methodChannel, (call) {
calls.add(call.method);
return handler(call);
});
}
Future<void> watch() async {
container.listen(batteryOptimizationIgnoredProvider, (_, _) {});
await pumpEventQueue();
}
bool? ignored() => container.read(batteryOptimizationIgnoredProvider).value;
setUp(() {
calls = [];
listening = false;
messenger.setMockStreamHandler(
eventChannel,
MockStreamHandler.inline(
onListen: (_, sink) {
listening = true;
events = sink;
sink.success(false);
},
onCancel: (_) => listening = false,
),
);
container = ProviderContainer(
overrides: [
batteryOptimizationIgnoredProvider.overrideWith(
_AndroidBatteryOptimizationIgnored.new,
),
],
);
});
tearDown(() {
messenger.setMockMethodCallHandler(methodChannel, null);
messenger.setMockStreamHandler(eventChannel, null);
container.dispose();
});
test('follows the allowlist while watched and stops after', () async {
await watch();
expect(ignored(), isFalse);
events.success(true);
await pumpEventQueue();
expect(ignored(), isTrue);
container.dispose();
await pumpEventQueue();
expect(listening, isFalse);
container = ProviderContainer();
});
test(
'a request waits for the user and the answer comes from the stream',
() async {
answer((_) async {
events.success(true);
return null;
});
await watch();
await container
.read(batteryOptimizationIgnoredProvider.notifier)
.request();
await pumpEventQueue();
expect(calls, ['requestIgnoreOptimizations']);
expect(ignored(), isTrue);
},
);
test('a second tap while the prompt is up asks once', () async {
final prompt = Completer<Object?>();
answer((_) => prompt.future);
await watch();
final notifier = container.read(
batteryOptimizationIgnoredProvider.notifier,
);
final first = notifier.request();
await notifier.request();
prompt.complete();
await first;
expect(calls, ['requestIgnoreOptimizations']);
});
test('coming back to the app leaves the state to the stream', () async {
answer((_) async => null);
await watch();
container.read(appVisibleProvider.notifier).value = false;
container.read(appVisibleProvider.notifier).value = true;
await pumpEventQueue();
expect(calls, isEmpty);
expect(ignored(), isFalse);
});
test('a platform without the prompt counts as exempt', () async {
container.dispose();
container = ProviderContainer();
await watch();
expect(ignored(), isTrue);
expect(listening, isFalse);
});
});
}
-181
View File
@@ -1,181 +0,0 @@
import 'package:fl_clash/common/permission.dart';
import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/providers/config.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:riverpod/riverpod.dart';
import 'package:wifi_ssid/wifi_ssid.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const channel = MethodChannel('wifi_ssid');
late ProviderContainer container;
setUp(() {
container = ProviderContainer(
overrides: [
excludeSSIDsProvider.overrideWithValue(const ['Office Wi-Fi']),
],
);
});
tearDown(() async {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, null);
container.dispose();
});
test('maps permission results to the correct UI follow-up', () {
expect(
getLocationPermissionFollowUp(WifiSsidPermission.granted),
LocationPermissionFollowUp.none,
);
expect(
getLocationPermissionFollowUp(WifiSsidPermission.denied),
LocationPermissionFollowUp.showDeniedMessage,
);
expect(
getLocationPermissionFollowUp(WifiSsidPermission.permanentlyDenied),
LocationPermissionFollowUp.openSettings,
);
});
test(
'refreshes current SSID immediately after permission is granted',
() async {
final calls = <String>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
calls.add(call.method);
return switch (call.method) {
'checkPermission' => 1,
'requestPermission' => 0,
'getSsid' => 'Office Wi-Fi',
_ => null,
};
});
await Permissions.test(
supportsLocationPermissions: true,
).checkLocationPermissions(container.read);
expect(calls, ['checkPermission', 'requestPermission', 'getSsid']);
expect(
container.read(locationPermissionsProvider),
WifiSsidPermission.granted,
);
expect(container.read(currentSSIDProvider), 'Office Wi-Fi');
},
);
test('a safe mode build neither checks nor requests location', () async {
container.dispose();
container = ProviderContainer(
overrides: [
excludeSSIDsProvider.overrideWithValue(const ['Office Wi-Fi']),
safeModeProvider.overrideWithValue(true),
],
);
final calls = <String>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
calls.add(call.method);
return 0;
});
await Permissions.test(
supportsLocationPermissions: true,
).checkLocationPermissions(container.read);
expect(calls, isEmpty);
expect(
container.read(locationPermissionsProvider),
WifiSsidPermission.denied,
);
expect(container.read(currentSSIDProvider), isNull);
});
test('does not read SSID when permission remains denied', () async {
final calls = <String>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
calls.add(call.method);
return 1;
});
await Permissions.test(
supportsLocationPermissions: true,
).checkLocationPermissions(container.read);
expect(calls, ['checkPermission', 'requestPermission']);
expect(
container.read(locationPermissionsProvider),
WifiSsidPermission.denied,
);
expect(container.read(currentSSIDProvider), isNull);
});
test('auto-requests at most once per session while still denied', () async {
final calls = <String>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
calls.add(call.method);
return 1;
});
final permissions = Permissions.test(supportsLocationPermissions: true);
await permissions.checkLocationPermissions(container.read);
await permissions.checkLocationPermissions(container.read);
expect(calls.where((call) => call == 'requestPermission').length, 1);
});
test(
're-arms the auto-request when excludeSSIDs goes from empty to non-empty',
() async {
container = ProviderContainer();
final calls = <String>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
calls.add(call.method);
return 1;
});
final permissions = Permissions.test(supportsLocationPermissions: true);
await permissions.checkLocationPermissions(container.read);
expect(calls.where((call) => call == 'requestPermission'), isEmpty);
container.read(excludeSSIDsProvider.notifier).value = const [
'Office Wi-Fi',
];
await permissions.checkLocationPermissions(container.read);
await permissions.checkLocationPermissions(container.read);
expect(calls.where((call) => call == 'requestPermission').length, 1);
},
);
test('re-arms the auto-request and does not throw when requestPermission '
'fails', () async {
var requestPermissionCalls = 0;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
if (call.method == 'requestPermission') {
requestPermissionCalls++;
throw PlatformException(code: 'IN_PROGRESS');
}
return 1;
});
final permissions = Permissions.test(supportsLocationPermissions: true);
await expectLater(
permissions.checkLocationPermissions(container.read),
completes,
);
await permissions.checkLocationPermissions(container.read);
expect(requestPermissionCalls, 2);
});
}
+95 -31
View File
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/providers/config.dart';
import 'package:fl_clash/providers/on_demand.dart';
import 'package:fl_clash/providers/state.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/state.dart';
@@ -35,6 +36,22 @@ class _TestLocationPermissions extends LocationPermissions {
WifiSsidPermission build() => _initial;
}
class _TestBatteryOptimizationIgnored extends BatteryOptimizationIgnored {
_TestBatteryOptimizationIgnored(this._initial);
final bool _initial;
int requests = 0;
@override
Stream<bool> build() => Stream.value(_initial);
@override
Future<void> request() async {
requests++;
state = const AsyncData(true);
}
}
void main() {
late ProviderContainer container;
@@ -42,6 +59,7 @@ void main() {
WidgetTester tester, {
List<String> ssids = const [],
WifiSsidPermission permission = WifiSsidPermission.denied,
bool batteryIgnored = false,
bool isAndroid = false,
bool isMacOS = false,
bool isStart = false,
@@ -59,6 +77,9 @@ void main() {
locationPermissionsProvider.overrideWith(
() => _TestLocationPermissions(permission),
),
batteryOptimizationIgnoredProvider.overrideWith(
() => _TestBatteryOptimizationIgnored(batteryIgnored),
),
isStartProvider.overrideWithValue(isStart),
],
);
@@ -83,7 +104,7 @@ void main() {
await pumpView(tester);
expect(find.text('SSIDs are empty'), findsOneWidget);
expect(find.text('Add'), findsOneWidget);
expect(find.byTooltip('Add'), findsOneWidget);
expect(find.text('Select all'), findsNothing);
expect(find.byGlyph(AppGlyphs.delete), findsNothing);
});
@@ -107,7 +128,7 @@ void main() {
expect(find.byGlyph(AppGlyphs.delete), findsOneWidget);
expect(find.text('Select all'), findsOneWidget);
expect(find.text('Add'), findsNothing);
expect(find.byTooltip('Add'), findsNothing);
});
testWidgets('select all takes every SSID, and pressing it again clears', (
@@ -123,7 +144,11 @@ void main() {
await tester.tap(find.text('Select all'));
await tester.pumpAndSettle();
expect(find.text('Add'), findsOneWidget, reason: 'selection was cleared');
expect(
find.byTooltip('Add'),
findsOneWidget,
reason: 'selection was cleared',
);
});
testWidgets('deleting removes the selected SSIDs and clears the selection', (
@@ -138,7 +163,11 @@ void main() {
expect(container.read(excludeSSIDsProvider), ['Office']);
expect(find.text('Home'), findsNothing);
expect(find.text('Add'), findsOneWidget, reason: 'selection was cleared');
expect(
find.byTooltip('Add'),
findsOneWidget,
reason: 'selection was cleared',
);
});
testWidgets('the location prerequisite reflects a denied permission', (
@@ -224,27 +253,67 @@ void main() {
expect(find.text('Location permission'), findsOneWidget);
});
testWidgets('while running the battery item explains instead of asking', (
testWidgets('authorizing the battery item asks and shows the answer', (
tester,
) async {
await pumpView(tester, isAndroid: true, isStart: true);
container.read(viewSizeProvider.notifier).value = const Size(1400, 1000);
final appLocalizations = AppLocalizations.of(
tester.element(find.byType(OnDemandView)),
await pumpView(tester, isAndroid: true);
final batteryItem = find.ancestor(
of: find.text('Ignore battery optimization'),
matching: find.byType(DecorationListItem),
);
expect(find.bySemanticsLabel(appLocalizations.tapToAuthorize), findsOne);
await tester.tap(find.byTooltip(appLocalizations.tip));
await tester.tap(
find.descendant(of: batteryItem, matching: find.text('Tap to authorize')),
);
await tester.pumpAndSettle();
final notifier =
container.read(batteryOptimizationIgnoredProvider.notifier)
as _TestBatteryOptimizationIgnored;
expect(notifier.requests, 1);
expect(
find.text(appLocalizations.batteryOptimizationStatusTip),
find.descendant(
of: batteryItem,
matching: find.bySemanticsLabel('Authorized'),
),
findsOneWidget,
);
});
testWidgets('the authorize action sits on its own line under the text', (
testWidgets('an exempt app does not ask again', (tester) async {
await pumpView(tester, isAndroid: true, batteryIgnored: true);
final batteryItem = find.ancestor(
of: find.text('Ignore battery optimization'),
matching: find.byType(DecorationListItem),
);
await tester.tap(
find.descendant(of: batteryItem, matching: find.text('Authorized')),
);
await tester.pumpAndSettle();
final notifier =
container.read(batteryOptimizationIgnoredProvider.notifier)
as _TestBatteryOptimizationIgnored;
expect(notifier.requests, 0);
});
testWidgets('while running the battery item still shows and asks', (
tester,
) async {
await pumpView(tester, isAndroid: true, isStart: true);
await tester.tap(find.text('Tap to authorize').first);
await tester.pumpAndSettle();
final notifier =
container.read(batteryOptimizationIgnoredProvider.notifier)
as _TestBatteryOptimizationIgnored;
expect(notifier.requests, 1);
expect(find.byTooltip('Tip'), findsNothing);
});
testWidgets('the authorize action trails its row, the reason the group', (
tester,
) async {
await pumpView(tester, isMacOS: true, locale: const Locale('ru'));
@@ -252,25 +321,20 @@ void main() {
final appLocalizations = AppLocalizations.of(
tester.element(find.byType(OnDemandView)),
);
final row = tester.getRect(find.byType(DecorationListItem).first);
final desc = find.text(appLocalizations.locationPermissionDesc);
final button = find
.ancestor(
of: find.text(appLocalizations.tapToAuthorize),
matching: find.byType(FilledButton),
)
.first;
final button = tester.getRect(
find
.ancestor(
of: find.text(appLocalizations.tapToAuthorize),
matching: find.byType(FilledButton),
)
.first,
);
expect(
tester.getTopLeft(button).dy,
greaterThanOrEqualTo(tester.getBottomLeft(desc).dy),
);
expect(
tester.getRect(button).right,
closeTo(
tester.getRect(find.byType(DecorationListItem).first).right - 16,
1,
),
);
expect(button.center.dy, closeTo(row.center.dy, 1));
expect(button.right, closeTo(row.right - 16, 1));
expect(tester.getTopLeft(desc).dy, greaterThanOrEqualTo(row.bottom));
});
testWidgets('a desktop that is not macOS asks for no prerequisite', (