Skip to content
Open
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
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" />

<application
android:name=".MainApplication"
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
}
}
}
Loading