Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,28 @@ on Mobile:

### Android

WiFi ssid policy

```yaml
proxy-groups:
- name: "🚀 节点选择"
type: select
proxies: [...]
ssid-policy:
"ip:192.168.10.": "home" # 匹配 192.168.10.x(家里)
"ip:10.10.": "office" # 匹配 10.10.x.x(公司)
"HomeWiFi": "DIRECT" # SSID
"cellular": "hk" # 移动网络
"default": "auto" # 默认规则
```

Support the following actions

```bash
com.follow.clash.action.START

com.follow.clash.action.STOP

com.follow.clash.action.TOGGLE
```

Expand Down
2 changes: 1 addition & 1 deletion android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ android {

defaultConfig {
applicationId = "com.follow.clash"
minSdk = flutter.minSdkVersion
minSdk = libs.versions.minSdk.get().toInt()
targetSdk = libs.versions.targetSdk.get().toInt()
versionCode = flutter.versionCode
versionName = flutter.versionName
Expand Down
4 changes: 4 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<!-- ACCESS_BACKGROUND_LOCATION is required to get SSID when device is locked (Android 10+) -->
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission
android:name="android.permission.QUERY_ALL_PACKAGES"
Expand Down
3 changes: 3 additions & 0 deletions android/core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ android {

defaultConfig {
minSdk = libs.versions.minSdk.get().toInt()
ndk {
abiFilters.add("arm64-v8a")
}
}


Expand Down
1 change: 1 addition & 0 deletions android/gradle.properties
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
org.gradle.jvmargs=-Xmx4G
android.useAndroidX=true
android.enableJetifier=true
org.gradle.caching=true
2 changes: 1 addition & 1 deletion android/gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[versions]
#agp = "8.10.1"
firebaseBom = "34.2.0"
minSdk = "23"
minSdk = "31"
targetSdk = "36"
compileSdk = "36"
ndkVersion = "28.0.13004108"
Expand Down
156 changes: 156 additions & 0 deletions lib/controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:url_launcher/url_launcher.dart';

import 'common/common.dart';
import 'core/network_monitor.dart';
import 'database/database.dart';
import 'models/models.dart';
import 'providers/database.dart';
Expand Down Expand Up @@ -540,6 +541,130 @@ extension ProxiesControllerExt on AppController {
int addSortNum() {
return _ref.read(sortNumProvider.notifier).add();
}

Future<void> applySsidPolicy(
String networkType,
String ssid,
String wifiIp,
) async {
try {
// Check if proxy is running
if (globalState.isStart != true) {
return;
}

// Get the current running config from the core
final currentProfileId = _ref.read(currentProfileIdProvider);
if (currentProfileId == null) {
return;
}

// Get the config that's currently loaded (after scripts have processed it)
final configMap = await coreController.getConfig(currentProfileId);

// Extract proxy-groups from config
final proxyGroups = configMap['proxy-groups'] as List?;
if (proxyGroups == null || proxyGroups.isEmpty) {
return;
}

// Determine network key for policy lookup
final networkKey = networkType == 'wifi' ? ssid : networkType;

commonPrint.log(
'Applying SSID policy for network: $networkKey${networkType == 'wifi' && wifiIp.isNotEmpty ? " (IP: $wifiIp)" : ""}',
);

// Get current proxy groups to validate target proxies exist
final currentGroups = groups;

// Apply policy for each proxy group that has ssid-policy defined
for (final groupData in proxyGroups) {
if (groupData is! Map) continue;

final groupName = groupData['name'] as String?;
final ssidPolicyData = groupData['ssid-policy'] as Map?;

if (groupName == null ||
ssidPolicyData == null ||
ssidPolicyData.isEmpty) {
continue;
}

// Convert ssidPolicyData to Map<String, String>
final policy = ssidPolicyData.map(
(key, value) => MapEntry(key.toString(), value.toString()),
);

// Look up target proxy with priority: IP segment match > SSID/network > default
String? targetProxy;
String? matchedKey;

// For WiFi, try IP segment match first (e.g., 'ip:192.168.2' matches '192.168.2.100')
if (networkType == 'wifi' && wifiIp.isNotEmpty) {
for (final key in policy.keys) {
if (key.startsWith('ip:')) {
final ipSegment = key.substring(3); // Remove 'ip:' prefix
if (wifiIp.startsWith(ipSegment)) {
targetProxy = policy[key];
matchedKey = key;
break;
}
}
}
}

// If no IP match, try SSID/network match
if (targetProxy == null && policy.containsKey(networkKey)) {
targetProxy = policy[networkKey];
matchedKey = networkKey;
}

// Finally try default
if (targetProxy == null) {
targetProxy = policy['default'];
matchedKey = 'default';
}

if (targetProxy != null && targetProxy.isNotEmpty) {
// Find the group to validate the proxy exists
final group = currentGroups
.where((g) => g.name == groupName)
.firstOrNull;

if (group == null || group.type != GroupType.Selector) {
commonPrint.log(
'SSID Policy: Group "$groupName" not found or not a Selector type, skipping',
);
continue;
}

// Check if target proxy exists in the group's proxy list
final proxyExists = group.all.any(
(proxy) => proxy.name == targetProxy,
);

if (!proxyExists) {
commonPrint.log(
'SSID Policy: Proxy "$targetProxy" not found in group "$groupName", skipping',
);
continue;
}

commonPrint.log(
'SSID Policy: Switching $groupName to $targetProxy (matched: $matchedKey)',
);
updateCurrentSelectedMap(groupName, targetProxy);
changeProxyDebounce(groupName, targetProxy);
}
}
} catch (e) {
commonPrint.log(
'Error applying SSID policy: $e',
logLevel: LogLevel.warning,
);
}
}
}

extension SetupControllerExt on AppController {
Expand All @@ -565,6 +690,9 @@ extension SetupControllerExt on AppController {
}
await globalState.handleStart([updateRunTime, updateTraffic]);
applyProfileDebounce(force: true, silence: true);

// Start network monitoring and apply initial SSID policy
_startNetworkMonitoring();
} else {
globalState.needInitStatus = false;
await applyProfile(
Expand All @@ -573,6 +701,9 @@ extension SetupControllerExt on AppController {
await globalState.handleStart([updateRunTime, updateTraffic]);
},
);

// Start network monitoring and apply initial SSID policy
_startNetworkMonitoring();
}
} else {
await globalState.handleStop();
Expand All @@ -581,7 +712,32 @@ extension SetupControllerExt on AppController {
_ref.read(totalTrafficProvider.notifier).value = Traffic();
_ref.read(runTimeProvider.notifier).value = null;
addCheckIp();

// Stop network monitoring when proxy stops
_stopNetworkMonitoring();
}
}

void _startNetworkMonitoring() {
// Only support SSID policy on Android and macOS
if (!Platform.isAndroid && !Platform.isMacOS) {
return;
}

final monitor = NetworkStateMonitor();
monitor.onNetworkStateChanged = (networkType, ssid, wifiIp) {
applySsidPolicy(networkType, ssid, wifiIp);
};
monitor.startMonitoring();
}

void _stopNetworkMonitoring() {
// Only support SSID policy on Android and macOS
if (!Platform.isAndroid && !Platform.isMacOS) {
return;
}

NetworkStateMonitor().stopMonitoring();
}

Future<bool> needSetup() async {
Expand Down
Loading