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:
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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', (
|
||||
|
||||
Reference in New Issue
Block a user