Skip to content

Commit 1f02424

Browse files
ovironclaude
andcommitted
feat(network-rules): rewrite auto VPN-by-network into a resident module
Move the observer and decision engine out of the UI process (where Android reclaimed them once the app was backgrounded) into a resident foreground service in the default process, so a network change is acted on even with the UI killed and the VPN running in :remote. The snapshot {type, ssid, networkId} is computed natively in Kotlin from the NOT_VPN network; the callback is registered with FLAG_INCLUDE_LOCATION_INFO so the SSID is not redacted on API 31+. The engine is authoritative in Kotlin (android/common/networkrules) and reads a rules mirror that Dart writes atomically to filesDir/network-rules.json on every change; the Dart engine stays as the in-app preview and the shared test contract. Adds an explicit baseline (defaultAction: turn on / off / leave unchanged, default leave) so leaving a matched network no longer strands the VPN. A manual toggle now wins over the rules until the network actually changes (keyed by networkHandle). Rule precedence is by specificity (a named-Wi-Fi rule beats a generic any-Wi-Fi rule regardless of list order); Ethernet is a matchable type; a rule whose conditions this build cannot parse is shown as invalid instead of silently inert. The classic (no-byedpi) flavor now ships zero byedpi: setup.dart strips the byedpi data assets from the bundle for non-byedpi builds (the native lib was already flavor-gated). Removes the old in-UI RuleEngineRunner, the Dart dispatch effect, and the UnderlyingNetworkPlugin. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 720cb77 commit 1f02424

59 files changed

Lines changed: 2142 additions & 500 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,4 +62,6 @@ CLAUDE.md
6262
/libclash/
6363
/android/app/src/main/jniLibs/
6464
/env.json
65-
devtools_options.yaml
65+
devtools_options.yaml
66+
# Transient: byedpi assets stashed out of non-byedpi builds by setup.dart
67+
.byedpi-asset-stash/

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
1+
## v0.15.0
2+
3+
- Network rules (auto VPN on/off by network) rewritten into a self-contained module. The observer and decision engine now run in a resident foreground service in the default process, so a network change (e.g. Wi-Fi to cellular with the screen off and the UI killed) is still acted on. Previously the logic lived in the UI process and silently stopped working once Android reclaimed it
4+
5+
- Added an explicit baseline: "When no rule matches" can leave the VPN unchanged (default), force it on, or force it off, so leaving a matched network no longer strands the VPN in whatever state it was last left in
6+
7+
- A manual VPN toggle now wins over the rules until the network actually changes, instead of being immediately reverted by the next network event
8+
9+
- Rule precedence is now by specificity (a named-Wi-Fi rule beats a generic "any Wi-Fi" rule regardless of list order); added Ethernet as a matchable network type; rules whose conditions a newer version wrote and this build cannot parse are shown as invalid instead of appearing active but dead
10+
111
## v0.14.0
212

313
- In-app core version switching (Tools -> Engine -> Library version): list, download, and run any ABI-compatible libmihomo / libbyedpi release without reinstalling the APK. Each downloaded `.aar` is verified on device (SHA-256 + detached GPG signature against the pinned signing key) before its `.so` is extracted to app-internal storage

android/app/src/main/AndroidManifest.xml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,5 +130,24 @@
130130
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
131131
</intent-filter>
132132
</receiver>
133+
134+
<service
135+
android:name=".networkrules.NetworkRulesService"
136+
android:exported="false"
137+
android:foregroundServiceType="specialUse">
138+
<property
139+
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
140+
android:value="network_rules" />
141+
</service>
142+
143+
<receiver
144+
android:name=".networkrules.NetworkRulesBootReceiver"
145+
android:enabled="false"
146+
android:exported="false">
147+
<intent-filter>
148+
<action android:name="android.intent.action.BOOT_COMPLETED" />
149+
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
150+
</intent-filter>
151+
</receiver>
133152
</application>
134153
</manifest>

android/app/src/main/kotlin/com/follow/clash/MainActivity.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ import android.os.Bundle
44
import com.follow.clash.common.GlobalState
55
import com.follow.clash.plugins.AppPlugin
66
import com.follow.clash.plugins.LibraryPlugin
7+
import com.follow.clash.plugins.NetworkRulesPlugin
78
import com.follow.clash.plugins.ServicePlugin
89
import com.follow.clash.plugins.TilePlugin
9-
import com.follow.clash.plugins.UnderlyingNetworkPlugin
1010
import io.flutter.embedding.android.FlutterActivity
1111
import io.flutter.embedding.engine.FlutterEngine
1212
import kotlinx.coroutines.CoroutineScope
@@ -26,7 +26,7 @@ class MainActivity : FlutterActivity(),
2626
flutterEngine.plugins.add(AppPlugin())
2727
flutterEngine.plugins.add(ServicePlugin())
2828
flutterEngine.plugins.add(TilePlugin())
29-
flutterEngine.plugins.add(UnderlyingNetworkPlugin())
29+
flutterEngine.plugins.add(NetworkRulesPlugin())
3030
flutterEngine.plugins.add(LibraryPlugin())
3131
State.flutterEngine = flutterEngine
3232
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.follow.clash.networkrules
2+
3+
import android.content.BroadcastReceiver
4+
import android.content.Context
5+
import android.content.Intent
6+
7+
// Restarts the resident service after boot / app update, but only while the
8+
// feature is enabled. Disabled by default; toggled by NetworkRulesManager.
9+
class NetworkRulesBootReceiver : BroadcastReceiver() {
10+
override fun onReceive(context: Context, intent: Intent) {
11+
when (intent.action) {
12+
Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED -> {
13+
if (NetworkRulesManager.isEnabled(context)) {
14+
NetworkRulesManager.start(context)
15+
}
16+
}
17+
}
18+
}
19+
}
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
package com.follow.clash.networkrules
2+
3+
import android.content.Context
4+
import android.os.SystemClock
5+
import com.follow.clash.RunState
6+
import com.follow.clash.State
7+
import com.follow.clash.common.GlobalState
8+
import com.follow.clash.common.networkrules.NetworkDecision
9+
import com.follow.clash.common.networkrules.NetworkRulesCodec
10+
import com.follow.clash.common.networkrules.NetworkRulesEngine
11+
import com.follow.clash.common.networkrules.NetworkRuleType
12+
import com.follow.clash.common.networkrules.NetworkSnapshot
13+
import com.follow.clash.common.networkrules.RulesMirror
14+
import kotlinx.coroutines.CoroutineScope
15+
import kotlinx.coroutines.Dispatchers
16+
import kotlinx.coroutines.Job
17+
import kotlinx.coroutines.SupervisorJob
18+
import kotlinx.coroutines.flow.drop
19+
import kotlinx.coroutines.launch
20+
import kotlinx.coroutines.sync.Mutex
21+
import kotlinx.coroutines.sync.withLock
22+
import java.io.File
23+
24+
data class NetworkRulesStatus(
25+
val type: NetworkRuleType,
26+
val ssid: String?,
27+
val decision: NetworkDecision,
28+
val reason: String,
29+
val overridden: Boolean,
30+
)
31+
32+
private data class ManualOverride(val networkKey: Long, val running: Boolean)
33+
34+
// The brain: reads the rules mirror, computes the decision for the current
35+
// network and actuates the VPN through the same headless seam the boot path
36+
// uses (State.handleStart/StopServiceAction, serialised by State.runLock).
37+
// Manual toggles win until the network actually changes (keyed by networkKey).
38+
object NetworkRulesController {
39+
40+
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
41+
private val mutex = Mutex()
42+
43+
private var observer: NetworkRulesObserver? = null
44+
private var runStateJob: Job? = null
45+
46+
private var currentKey: Long = NO_NETWORK
47+
private var override: ManualOverride? = null
48+
private var lastEngineDesired: Boolean? = null
49+
private var engineGuardUntil: Long = 0
50+
51+
@Volatile
52+
private var lastSnapshot: NetworkSnapshot? = null
53+
54+
@Volatile
55+
private var lastSnapshotKey: Long = NO_NETWORK
56+
57+
@Volatile
58+
var status: NetworkRulesStatus? = null
59+
private set
60+
61+
@Volatile
62+
var statusListener: ((NetworkRulesStatus) -> Unit)? = null
63+
64+
fun start(context: Context) {
65+
if (observer != null) return
66+
observer = NetworkRulesObserver(context.applicationContext) { snapshot, key ->
67+
onSnapshot(snapshot, key)
68+
}.also { it.start() }
69+
runStateJob = scope.launch {
70+
State.runStateFlow.drop(1).collect { onRunStateChanged(it) }
71+
}
72+
}
73+
74+
fun stop() {
75+
observer?.stop()
76+
observer = null
77+
runStateJob?.cancel()
78+
runStateJob = null
79+
mutexReset()
80+
}
81+
82+
private fun mutexReset() {
83+
override = null
84+
lastEngineDesired = null
85+
currentKey = NO_NETWORK
86+
}
87+
88+
// Re-run the last decision after the rules mirror changed (rule edited /
89+
// default toggled) so the user sees the effect without a network change.
90+
fun reevaluate() {
91+
val snapshot = lastSnapshot ?: return
92+
scope.launch { onSnapshot(snapshot, lastSnapshotKey) }
93+
}
94+
95+
private suspend fun onSnapshot(snapshot: NetworkSnapshot, key: Long) {
96+
lastSnapshot = snapshot
97+
lastSnapshotKey = key
98+
val decision: NetworkDecision
99+
val reason: String
100+
val overridden: Boolean
101+
mutex.withLock {
102+
if (key != currentKey) {
103+
override = null
104+
currentKey = key
105+
}
106+
val mirror = readMirror()
107+
decision = NetworkRulesEngine.resolve(mirror, snapshot)
108+
reason = buildReason(mirror, snapshot, decision)
109+
overridden = override?.networkKey == key
110+
}
111+
publish(NetworkRulesStatus(snapshot.type, snapshot.ssid, decision, reason, overridden))
112+
if (!overridden) actuate(decision)
113+
}
114+
115+
// Sets lastEngineDesired + the guard window atomically under the mutex, then
116+
// performs the (suspending) State call OUTSIDE the lock so we never hold our
117+
// mutex across State.runLock. The emission that results lands inside the
118+
// guard window and is classified as engine-initiated, not a manual toggle.
119+
private suspend fun actuate(decision: NetworkDecision) {
120+
val running = State.runStateFlow.value == RunState.START
121+
var action: (suspend () -> Unit)? = null
122+
mutex.withLock {
123+
when (decision) {
124+
NetworkDecision.START -> {
125+
lastEngineDesired = true
126+
if (!running) {
127+
engineGuardUntil = SystemClock.elapsedRealtime() + GUARD_MS
128+
action = State::handleStartServiceAction
129+
}
130+
}
131+
132+
NetworkDecision.STOP -> {
133+
lastEngineDesired = false
134+
if (running) {
135+
engineGuardUntil = SystemClock.elapsedRealtime() + GUARD_MS
136+
action = State::handleStopServiceAction
137+
}
138+
}
139+
140+
NetworkDecision.LEAVE_AS_IS -> Unit
141+
}
142+
}
143+
action?.invoke()
144+
}
145+
146+
// A run-state flip outside the engine's own guard window AND differing from
147+
// what the engine last asked for is a manual toggle; record it so the engine
148+
// stops fighting the user on this network. Guard read + override write are
149+
// under the mutex so they are atomic w.r.t. actuate().
150+
private suspend fun onRunStateChanged(state: RunState) {
151+
if (state == RunState.PENDING) return
152+
val running = state == RunState.START
153+
mutex.withLock {
154+
if (SystemClock.elapsedRealtime() < engineGuardUntil) return@withLock
155+
if (running != lastEngineDesired) {
156+
override = ManualOverride(currentKey, running)
157+
}
158+
}
159+
}
160+
161+
private fun readMirror(): RulesMirror {
162+
val file = File(GlobalState.application.filesDir, MIRROR_FILE)
163+
if (!file.exists()) return NetworkRulesCodec.disabled
164+
return try {
165+
NetworkRulesCodec.parse(file.readText())
166+
} catch (e: Throwable) {
167+
GlobalState.log("network-rules mirror read failed: $e")
168+
NetworkRulesCodec.disabled
169+
}
170+
}
171+
172+
private fun buildReason(
173+
mirror: RulesMirror,
174+
snapshot: NetworkSnapshot,
175+
decision: NetworkDecision,
176+
): String {
177+
val matched = NetworkRulesEngine.evaluate(mirror.rules, snapshot)
178+
val where = describe(snapshot)
179+
return if (matched != null) {
180+
"rule matched on $where -> ${decision.name}"
181+
} else {
182+
"no rule on $where -> default ${decision.name}"
183+
}
184+
}
185+
186+
private fun describe(snapshot: NetworkSnapshot): String = when (snapshot.type) {
187+
NetworkRuleType.WIFI -> "wifi:${snapshot.ssid ?: "<unknown>"}"
188+
NetworkRuleType.CELLULAR -> "cellular"
189+
NetworkRuleType.ETHERNET -> "ethernet"
190+
NetworkRuleType.NONE -> "none"
191+
}
192+
193+
private fun publish(next: NetworkRulesStatus) {
194+
status = next
195+
statusListener?.invoke(next)
196+
}
197+
198+
private const val MIRROR_FILE = "network-rules.json"
199+
private const val GUARD_MS = 6000L
200+
private const val NO_NETWORK = Long.MIN_VALUE
201+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package com.follow.clash.networkrules
2+
3+
import android.content.ComponentName
4+
import android.content.Context
5+
import android.content.Intent
6+
import android.content.pm.PackageManager
7+
import androidx.core.content.ContextCompat
8+
import com.follow.clash.common.GlobalState
9+
import com.follow.clash.common.networkrules.NetworkRulesCodec
10+
import java.io.File
11+
12+
// Lifecycle entry points shared by the plugin (UI toggle) and the boot
13+
// receiver. The feature is "on" iff the master toggle is on in the mirror.
14+
object NetworkRulesManager {
15+
16+
fun isEnabled(context: Context): Boolean {
17+
val file = File(context.applicationContext.filesDir, "network-rules.json")
18+
if (!file.exists()) return false
19+
return try {
20+
NetworkRulesCodec.parse(file.readText()).enabled
21+
} catch (_: Throwable) {
22+
false
23+
}
24+
}
25+
26+
fun start(context: Context) {
27+
val app = context.applicationContext
28+
setBootReceiverEnabled(app, true)
29+
val intent = Intent(app, NetworkRulesService::class.java)
30+
try {
31+
ContextCompat.startForegroundService(app, intent)
32+
} catch (e: Throwable) {
33+
// Android 16 can reject a specialUse FGS start from a BOOT_COMPLETED
34+
// receiver. Degrade to a normal start instead of crashing; the
35+
// service promotes itself in onCreate via startForegroundCompat.
36+
GlobalState.log("network-rules FGS start failed, falling back: $e")
37+
try {
38+
app.startService(intent)
39+
} catch (e2: Throwable) {
40+
GlobalState.log("network-rules service start failed: $e2")
41+
}
42+
}
43+
}
44+
45+
fun stop(context: Context) {
46+
val app = context.applicationContext
47+
setBootReceiverEnabled(app, false)
48+
app.stopService(Intent(app, NetworkRulesService::class.java))
49+
}
50+
51+
private fun setBootReceiverEnabled(context: Context, enabled: Boolean) {
52+
val state = if (enabled) {
53+
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
54+
} else {
55+
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
56+
}
57+
context.packageManager.setComponentEnabledSetting(
58+
ComponentName(context, NetworkRulesBootReceiver::class.java),
59+
state,
60+
PackageManager.DONT_KILL_APP,
61+
)
62+
}
63+
}

0 commit comments

Comments
 (0)