Skip to content

Commit fea19dc

Browse files
author
fangsy
committed
增加“断开指定WiFi时连接到VPN”选项
1 parent c715242 commit fea19dc

9 files changed

Lines changed: 211 additions & 7 deletions

File tree

.idea/codeStyles/Project.xml

Lines changed: 0 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/src/main/AndroidManifest.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@
1616
tools:ignore="QueryAllPackagesPermission" />
1717
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
1818
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
19+
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
20+
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
21+
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
22+
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
1923

2024
<application
2125
android:name=".MainApplication"

app/src/main/java/com/github/kr328/clash/MainActivity.kt

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,35 @@ class MainActivity : BaseActivity<MainDesign>() {
157157
requestPermissionLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS)
158158
}
159159
}
160+
161+
// Request location permission for WiFi SSID access
162+
val permissionsToRequest = mutableListOf<String>()
163+
if (ContextCompat.checkSelfPermission(
164+
this,
165+
android.Manifest.permission.ACCESS_FINE_LOCATION
166+
) != PackageManager.PERMISSION_GRANTED) {
167+
permissionsToRequest.add(android.Manifest.permission.ACCESS_FINE_LOCATION)
168+
}
169+
170+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
171+
ContextCompat.checkSelfPermission(
172+
this,
173+
android.Manifest.permission.ACCESS_BACKGROUND_LOCATION
174+
) != PackageManager.PERMISSION_GRANTED) {
175+
// Note: Background location usually needs to be requested separately or incrementally
176+
// depending on target SDK, but adding it here for completeness if the user flow allows.
177+
// For Android 11+, you must request foreground first, then background.
178+
// For simplicity in this snippet, we'll add it if possible, but be aware of platform restrictions.
179+
permissionsToRequest.add(android.Manifest.permission.ACCESS_BACKGROUND_LOCATION)
180+
}
181+
182+
if (permissionsToRequest.isNotEmpty()) {
183+
ActivityCompat.requestPermissions(
184+
this,
185+
permissionsToRequest.toTypedArray(),
186+
1001
187+
)
188+
}
160189
}
161190
}
162191

app/src/main/java/com/github/kr328/clash/MainApplication.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import java.io.FileOutputStream
1414

1515
@Suppress("unused")
1616
class MainApplication : Application() {
17+
private var wifiAutomator: WifiAutomator? = null
18+
1719
override fun attachBaseContext(base: Context?) {
1820
super.attachBaseContext(base)
1921

@@ -30,11 +32,18 @@ class MainApplication : Application() {
3032

3133
if (processName == packageName) {
3234
Remote.launch()
35+
wifiAutomator = WifiAutomator(this)
36+
wifiAutomator?.start()
3337
} else {
3438
sendServiceRecreated()
3539
}
3640
}
3741

42+
override fun onTerminate() {
43+
super.onTerminate()
44+
wifiAutomator?.stop()
45+
}
46+
3847
private fun extractGeoFiles() {
3948
clashDir.mkdirs()
4049

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
package com.github.kr328.clash
2+
3+
import android.Manifest
4+
import android.content.Context
5+
import android.content.pm.PackageManager
6+
import android.net.ConnectivityManager
7+
import android.net.Network
8+
import android.net.NetworkCapabilities
9+
import android.net.NetworkRequest
10+
import android.net.wifi.WifiInfo
11+
import android.net.wifi.WifiManager
12+
import android.os.Build
13+
import androidx.core.content.ContextCompat
14+
import com.github.kr328.clash.common.log.Log
15+
import com.github.kr328.clash.service.store.ServiceStore
16+
import com.github.kr328.clash.util.startClashService
17+
import com.github.kr328.clash.util.stopClashService
18+
19+
class WifiAutomator(private val context: Context) {
20+
private val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
21+
private val wifiManager = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
22+
private val serviceStore = ServiceStore(context)
23+
private var lastConnectedSsid: String? = null
24+
25+
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
26+
override fun onAvailable(network: Network) {
27+
super.onAvailable(network)
28+
updateCurrentSsid(network)
29+
}
30+
31+
override fun onLost(network: Network) {
32+
super.onLost(network)
33+
Log.d("WifiAutomator: Network Lost")
34+
35+
// Check if the lost network was the target WiFi
36+
if (serviceStore.autoConnectVpnOnWifiDisconnect) {
37+
Log.d("WifiAutomator: autoConnectVpnOnWifiDisconnect is enabled and lastConnectedSsid is $lastConnectedSsid")
38+
val targetSsid = serviceStore.wifiSsidForVpn
39+
if (targetSsid?.isNotEmpty() == true && lastConnectedSsid == targetSsid) {
40+
Log.d("WifiAutomator: Disconnected from target WiFi $targetSsid, starting Clash")
41+
context.startClashService()
42+
}
43+
} else {
44+
Log.d("WifiAutomator: autoConnectVpnOnWifiDisconnect is disabled")
45+
}
46+
}
47+
48+
override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
49+
super.onCapabilitiesChanged(network, networkCapabilities)
50+
updateCurrentSsid(network)
51+
}
52+
}
53+
54+
private fun updateCurrentSsid(network: Network) {
55+
val capabilities = connectivityManager.getNetworkCapabilities(network)
56+
if (capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true) {
57+
var ssid: String? = null
58+
59+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
60+
val wifiInfo = capabilities.transportInfo as? WifiInfo
61+
ssid = wifiInfo?.ssid
62+
}
63+
64+
// Fallback or if Q+ API returned null/unknown
65+
if (ssid == null || ssid == WifiManager.UNKNOWN_SSID) {
66+
// Check permissions before calling legacy API to avoid SecurityException
67+
// Note: ACCESS_BACKGROUND_LOCATION is needed for background access on Android 10+
68+
val hasFineLocation = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
69+
val hasBackgroundLocation = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
70+
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION) == PackageManager.PERMISSION_GRANTED
71+
} else {
72+
true
73+
}
74+
75+
if (hasFineLocation) {
76+
try {
77+
val info = wifiManager.connectionInfo
78+
if (info != null && info.supplicantState.name == "COMPLETED") {
79+
ssid = info.ssid
80+
}
81+
} catch (e: Exception) {
82+
Log.w("WifiAutomator: Failed to get legacy wifi info", e)
83+
}
84+
} else {
85+
Log.w("WifiAutomator: Missing location permission for legacy wifi info")
86+
}
87+
}
88+
89+
val cleanSsid = ssid?.trim('"')
90+
if (cleanSsid != null && cleanSsid != "<unknown ssid>" && cleanSsid != "0x") {
91+
lastConnectedSsid = cleanSsid
92+
Log.d("WifiAutomator: WiFi Connected to $cleanSsid")
93+
94+
if (serviceStore.autoConnectVpnOnWifiDisconnect) {
95+
val targetSsid = serviceStore.wifiSsidForVpn
96+
if (targetSsid?.isNotEmpty() == true && cleanSsid == targetSsid) {
97+
Log.d("WifiAutomator: Connected to trusted WiFi $targetSsid, stopping Clash")
98+
context.stopClashService()
99+
}
100+
}
101+
} else {
102+
Log.d("WifiAutomator: Failed to get valid SSID. Raw: $ssid")
103+
}
104+
}
105+
}
106+
107+
fun start() {
108+
val request = NetworkRequest.Builder()
109+
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
110+
.build()
111+
connectivityManager.registerNetworkCallback(request, networkCallback)
112+
113+
// Initial check
114+
val activeNetwork = connectivityManager.activeNetwork
115+
if (activeNetwork != null) {
116+
updateCurrentSsid(activeNetwork)
117+
}
118+
}
119+
120+
fun stop() {
121+
try {
122+
connectivityManager.unregisterNetworkCallback(networkCallback)
123+
} catch (e: Exception) {
124+
// Ignore if not registered
125+
}
126+
}
127+
}

design/src/main/java/com/github/kr328/clash/design/NetworkSettingsDesign.kt

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,28 @@ class NetworkSettingsDesign(
4141
val screen = preferenceScreen(context) {
4242
val vpnDependencies: MutableList<Preference> = mutableListOf()
4343

44+
lateinit var ssidInput: EditableTextPreference
45+
46+
val autoConnectVpnSwitch = switch(
47+
value = srvStore::autoConnectVpnOnWifiDisconnect,
48+
title = R.string.auto_connect_vpn_on_wifi_disconnect,
49+
summary = R.string.auto_connect_vpn_on_wifi_disconnect_summary,
50+
) {
51+
listener = OnChangedListener {
52+
ssidInput.enabled = srvStore.autoConnectVpnOnWifiDisconnect
53+
}
54+
}
55+
56+
ssidInput = editableText(
57+
value = srvStore::wifiSsidForVpn,
58+
adapter = NullableTextAdapter.String,
59+
title = R.string.wifi_ssid_for_vpn,
60+
placeholder = R.string.wifi_ssid_placeholder,
61+
empty = R.string.empty,
62+
) {
63+
enabled = srvStore.autoConnectVpnOnWifiDisconnect
64+
}
65+
4466
val vpn = switch(
4567
value = uiStore::enableVpn,
4668
icon = R.drawable.ic_baseline_vpn_lock,

design/src/main/res/values-zh/strings.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,10 @@
6868
<string name="settings">设置</string>
6969
<string name="show_traffic">显示流量</string>
7070
<string name="show_traffic_summary">在通知中自动刷新流量</string>
71+
<string name="auto_connect_vpn_on_wifi_disconnect">WiFi 断开时自动连接 VPN</string>
72+
<string name="auto_connect_vpn_on_wifi_disconnect_summary">WiFi 断开连接时自动连接到 VPN</string>
73+
<string name="wifi_ssid_for_vpn">WiFi SSID 名称</string>
74+
<string name="wifi_ssid_placeholder">输入 WiFi SSID(可选)</string>
7175
<string name="allow_clash_auto_restart">允许 Clash 自动重启</string>
7276
<string name="auto_restart">自动重启</string>
7377
<string name="stopped">已停止</string>

design/src/main/res/values/strings.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,11 @@
114114
<string name="auto_restart">Auto Restart</string>
115115
<string name="allow_clash_auto_restart">Allow clash auto restart</string>
116116

117+
<string name="auto_connect_vpn_on_wifi_disconnect">Auto Connect VPN on WiFi Disconnect</string>
118+
<string name="auto_connect_vpn_on_wifi_disconnect_summary">Automatically connect to VPN when WiFi is disconnected</string>
119+
<string name="wifi_ssid_for_vpn">WiFi SSID for VPN</string>
120+
<string name="wifi_ssid_placeholder">Enter WiFi SSID (optional)</string>
121+
117122
<string name="route_system_traffic">Route System Traffic</string>
118123
<string name="routing_via_vpn_service">Auto routing all system traffic via VpnService</string>
119124

service/src/main/java/com/github/kr328/clash/service/store/ServiceStore.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,4 +65,15 @@ class ServiceStore(context: Context) {
6565
key = "dynamic_notification",
6666
defaultValue = true
6767
)
68+
69+
var autoConnectVpnOnWifiDisconnect by store.boolean(
70+
key = "auto_connect_vpn_on_wifi_disconnect",
71+
defaultValue = false
72+
)
73+
74+
var wifiSsidForVpn by store.typedString(
75+
key = "wifi_ssid_for_vpn",
76+
from = { if (it.isBlank()) null else it },
77+
to = { it ?: "" }
78+
)
6879
}

0 commit comments

Comments
 (0)