Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
27a5dad
支持CFW的YAML parsers功能(prepend-proxies,prepend-proxy-groups,prepend-rul…
Feb 1, 2025
e684877
变更Build命令
Feb 1, 2025
b00ecb8
Merge branch 'MetaCubeX:main' into main
GkingF Feb 11, 2025
9c02eb1
Merge branch 'MetaCubeX:main' into main
GkingF Jun 24, 2025
ecccfbc
Merge branch 'MetaCubeX:main' into main
GkingF Jul 29, 2025
cb780d5
Merge branch 'MetaCubeX:main' into main
GkingF Sep 17, 2025
c361570
Merge branch 'MetaCubeX:main' into main
GkingF Dec 23, 2025
5b408bf
Merge branch 'MetaCubeX:main' into main
GkingF Feb 9, 2026
793ad93
Merge branch 'MetaCubeX:main' into main
GkingF Feb 10, 2026
c715242
replace keystore
Feb 10, 2026
fea19dc
增加“断开指定WiFi时连接到VPN”选项
Feb 25, 2026
68d056c
Bump version to 2.11.23.1 (211023)
actions-user Feb 27, 2026
7b07837
Merge branch 'main' of https://github.com/MetaCubeX/ClashMetaForAndroid
Mar 10, 2026
254f535
Merge branch 'main' of https://github.com/MetaCubeX/ClashMetaForAndroid
Apr 20, 2026
12fc6b3
Merge branch 'main' of https://github.com/MetaCubeX/ClashMetaForAndroid
May 28, 2026
05191f6
Merge branch 'main' of https://github.com/MetaCubeX/ClashMetaForAndroid
Jun 1, 2026
5af37ab
Merge branch 'main' of https://github.com/MetaCubeX/ClashMetaForAndroid
Jun 8, 2026
41d28b6
merge
Jun 8, 2026
6cb91ce
Merge branch 'main' of https://github.com/MetaCubeX/ClashMetaForAndroid
Aug 23, 2026
42c59ed
Merge branch 'main' of https://github.com/MetaCubeX/ClashMetaForAndroid
Sep 16, 2026
8814eb6
build(core): add missing yaml.v3 dependency to go.mod
Sep 16, 2026
d11277d
build(core): add missing yaml.v3 dependency to go.mod
Sep 16, 2026
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
7 changes: 0 additions & 7 deletions .idea/codeStyles/Project.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
tools:ignore="QueryAllPackagesPermission" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />

<!-- Workaround for Android 17+ cross profile loopback networking restrictions.
This permission allows CMFA to connect to loopback addresses and ports bound
Expand Down
39 changes: 34 additions & 5 deletions app/src/main/java/com/github/kr328/clash/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,35 @@ class MainActivity : BaseActivity<MainDesign>() {
requestPermissionLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS)
}
}

// Request location permission for WiFi SSID access
val permissionsToRequest = mutableListOf<String>()
if (ContextCompat.checkSelfPermission(
this,
android.Manifest.permission.ACCESS_FINE_LOCATION
) != PackageManager.PERMISSION_GRANTED) {
permissionsToRequest.add(android.Manifest.permission.ACCESS_FINE_LOCATION)
}

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
ContextCompat.checkSelfPermission(
this,
android.Manifest.permission.ACCESS_BACKGROUND_LOCATION
) != PackageManager.PERMISSION_GRANTED) {
// Note: Background location usually needs to be requested separately or incrementally
// depending on target SDK, but adding it here for completeness if the user flow allows.
// For Android 11+, you must request foreground first, then background.
// For simplicity in this snippet, we'll add it if possible, but be aware of platform restrictions.
permissionsToRequest.add(android.Manifest.permission.ACCESS_BACKGROUND_LOCATION)
}

if (permissionsToRequest.isNotEmpty()) {
ActivityCompat.requestPermissions(
this,
permissionsToRequest.toTypedArray(),
1001
)
}
setupShortcuts()
}

Expand All @@ -170,13 +199,13 @@ class MainActivity : BaseActivity<MainDesign>() {
if (uiStore.hideAppIcon) return

val flags = Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS or
Intent.FLAG_ACTIVITY_NO_ANIMATION
Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS or
Intent.FLAG_ACTIVITY_NO_ANIMATION

val toggle = ShortcutInfoCompat.Builder(this, "toggle_clash")
.setShortLabel(getString(DesignR.string.shortcut_toggle_short))
.setLongLabel(getString(DesignR.string.shortcut_toggle_long))
.setIcon(IconCompat.createWithResource(this, R.drawable.ic_toggle_all))
.setIcon(IconCompat.createWithResource(this, com.github.kr328.clash.R.drawable.ic_toggle_all))
.setIntent(
Intent(Intents.ACTION_TOGGLE_CLASH)
.setClassName(this, ExternalControlActivity::class.java.name)
Expand All @@ -188,7 +217,7 @@ class MainActivity : BaseActivity<MainDesign>() {
val start = ShortcutInfoCompat.Builder(this, "start_clash")
.setShortLabel(getString(DesignR.string.shortcut_start_short))
.setLongLabel(getString(DesignR.string.shortcut_start_long))
.setIcon(IconCompat.createWithResource(this, R.drawable.ic_toggle_on))
.setIcon(IconCompat.createWithResource(this, com.github.kr328.clash.R.drawable.ic_toggle_on))
.setIntent(
Intent(Intents.ACTION_START_CLASH)
.setClassName(this, ExternalControlActivity::class.java.name)
Expand All @@ -200,7 +229,7 @@ class MainActivity : BaseActivity<MainDesign>() {
val stop = ShortcutInfoCompat.Builder(this, "stop_clash")
.setShortLabel(getString(DesignR.string.shortcut_stop_short))
.setLongLabel(getString(DesignR.string.shortcut_stop_long))
.setIcon(IconCompat.createWithResource(this, R.drawable.ic_toggle_off))
.setIcon(IconCompat.createWithResource(this, com.github.kr328.clash.R.drawable.ic_toggle_off))
.setIntent(
Intent(Intents.ACTION_STOP_CLASH)
.setClassName(this, ExternalControlActivity::class.java.name)
Expand Down
9 changes: 9 additions & 0 deletions app/src/main/java/com/github/kr328/clash/MainApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import java.io.FileOutputStream
@Suppress("unused")
class MainApplication : Application() {

private var wifiAutomator: WifiAutomator? = null

override fun attachBaseContext(base: Context?) {
super.attachBaseContext(base)

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

if (processName == packageName) {
Remote.launch()
wifiAutomator = WifiAutomator(this)
wifiAutomator?.start()
} else {
sendServiceRecreated()
}
}

override fun onTerminate() {
super.onTerminate()
wifiAutomator?.stop()
}

private fun extractGeoFiles() {
clashDir.mkdirs()

Expand Down
127 changes: 127 additions & 0 deletions app/src/main/java/com/github/kr328/clash/WifiAutomator.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package com.github.kr328.clash

import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.net.wifi.WifiInfo
import android.net.wifi.WifiManager
import android.os.Build
import androidx.core.content.ContextCompat
import com.github.kr328.clash.common.log.Log
import com.github.kr328.clash.service.store.ServiceStore
import com.github.kr328.clash.util.startClashService
import com.github.kr328.clash.util.stopClashService

class WifiAutomator(private val context: Context) {
private val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
private val wifiManager = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
private val serviceStore = ServiceStore(context)
private var lastConnectedSsid: String? = null

private val networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
super.onAvailable(network)
updateCurrentSsid(network)
}

override fun onLost(network: Network) {
super.onLost(network)
Log.d("WifiAutomator: Network Lost")

// Check if the lost network was the target WiFi
if (serviceStore.autoConnectVpnOnWifiDisconnect) {
Log.d("WifiAutomator: autoConnectVpnOnWifiDisconnect is enabled and lastConnectedSsid is $lastConnectedSsid")
val targetSsid = serviceStore.wifiSsidForVpn
if (targetSsid?.isNotEmpty() == true && lastConnectedSsid == targetSsid) {
Log.d("WifiAutomator: Disconnected from target WiFi $targetSsid, starting Clash")
context.startClashService()
}
} else {
Log.d("WifiAutomator: autoConnectVpnOnWifiDisconnect is disabled")
}
}

override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
super.onCapabilitiesChanged(network, networkCapabilities)
updateCurrentSsid(network)
}
}

private fun updateCurrentSsid(network: Network) {
val capabilities = connectivityManager.getNetworkCapabilities(network)
if (capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true) {
var ssid: String? = null

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val wifiInfo = capabilities.transportInfo as? WifiInfo
ssid = wifiInfo?.ssid
}

// Fallback or if Q+ API returned null/unknown
if (ssid == null || ssid == WifiManager.UNKNOWN_SSID) {
// Check permissions before calling legacy API to avoid SecurityException
// Note: ACCESS_BACKGROUND_LOCATION is needed for background access on Android 10+
val hasFineLocation = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
val hasBackgroundLocation = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION) == PackageManager.PERMISSION_GRANTED
} else {
true
}

if (hasFineLocation) {
try {
val info = wifiManager.connectionInfo
if (info != null && info.supplicantState.name == "COMPLETED") {
ssid = info.ssid
}
} catch (e: Exception) {
Log.w("WifiAutomator: Failed to get legacy wifi info", e)
}
} else {
Log.w("WifiAutomator: Missing location permission for legacy wifi info")
}
}

val cleanSsid = ssid?.trim('"')
if (cleanSsid != null && cleanSsid != "<unknown ssid>" && cleanSsid != "0x") {
lastConnectedSsid = cleanSsid
Log.d("WifiAutomator: WiFi Connected to $cleanSsid")

if (serviceStore.autoConnectVpnOnWifiDisconnect) {
val targetSsid = serviceStore.wifiSsidForVpn
if (targetSsid?.isNotEmpty() == true && cleanSsid == targetSsid) {
Log.d("WifiAutomator: Connected to trusted WiFi $targetSsid, stopping Clash")
context.stopClashService()
}
}
} else {
Log.d("WifiAutomator: Failed to get valid SSID. Raw: $ssid")
}
}
}

fun start() {
val request = NetworkRequest.Builder()
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
.build()
connectivityManager.registerNetworkCallback(request, networkCallback)

// Initial check
val activeNetwork = connectivityManager.activeNetwork
if (activeNetwork != null) {
updateCurrentSsid(activeNetwork)
}
}

fun stop() {
try {
connectivityManager.unregisterNetworkCallback(networkCallback)
} catch (e: Exception) {
// Ignore if not registered
}
}
}
1 change: 1 addition & 0 deletions core/src/foss/golang/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ require (
golang.org/x/time v0.10.0 // indirect
golang.org/x/tools v0.24.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

replace cfa => ../../main/golang
Expand Down
1 change: 1 addition & 0 deletions core/src/foss/golang/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -323,5 +323,6 @@ golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k=
1 change: 1 addition & 0 deletions core/src/main/golang/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ require (
golang.org/x/time v0.10.0 // indirect
golang.org/x/tools v0.24.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

replace google.golang.org/protobuf => github.com/metacubex/protobuf-go v0.0.0-20260306035419-7ceee0674686
1 change: 1 addition & 0 deletions core/src/main/golang/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -323,5 +323,6 @@ golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k=
Loading