build: target Android SDK 37, use Rust 1.98.1 and add a safe mode run

Moves to AGP 9.4.1 and Gradle 9.8.0; Rust 1.98.1 lets macOS 27 load
rust_api. Android 17 treats the tun subnet as local network, so starting
the VPN asks for that permission and falls back to gvisor without it.
Signed release builds upload native symbols to Crashlytics.
This commit is contained in:
chen08209
2026-09-25 03:56:54 +08:00
parent c7be7023d3
commit 12403b063a
25 changed files with 464 additions and 83 deletions
+12
View File
@@ -1,3 +1,4 @@
import com.google.firebase.crashlytics.buildtools.gradle.CrashlyticsExtension
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import java.util.Properties
@@ -79,6 +80,10 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
configure<CrashlyticsExtension> {
nativeSymbolUploadEnabled = hasReleaseSigning
}
}
}
@@ -98,6 +103,13 @@ flutter {
source = "../.."
}
// The Crashlytics plugin finalizes R8 with the mapping upload but leaves the native symbol upload to the caller.
if (hasReleaseSigning) {
tasks.matching { it.name == "assembleRelease" || it.name == "bundleRelease" }.configureEach {
finalizedBy("uploadCrashlyticsSymbolFileRelease")
}
}
dependencies {
implementation(project(":service"))
implementation(project(":common"))
+1
View File
@@ -27,6 +27,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<application
android:name=".FlClashApplication"
@@ -1,6 +1,10 @@
package com.follow.clash
import android.Manifest
import android.content.pm.PackageManager
import android.net.VpnService
import android.os.Build
import androidx.core.content.ContextCompat
import com.follow.clash.common.GlobalState
import com.follow.clash.models.SharedState
import com.follow.clash.plugins.AppPlugin
@@ -33,6 +37,8 @@ internal interface ServiceStateHost {
fun isVpnPermissionGranted(): Boolean
fun isLocalNetworkPermissionGranted(): Boolean
fun tile(): TileGateway?
fun app(): AppGateway?
@@ -55,6 +61,8 @@ internal interface TileGateway {
internal interface AppGateway {
fun requestNotificationPermission(callback: (Boolean) -> Unit)
fun requestLocalNetworkPermission(callback: (Boolean) -> Unit)
fun prepareVpn(enable: Boolean, callback: (Boolean) -> Unit)
fun cancelVpnPreparation(callback: (Boolean) -> Unit)
@@ -100,6 +108,13 @@ internal object AndroidServiceStateHost : ServiceStateHost {
override fun isVpnPermissionGranted(): Boolean =
VpnService.prepare(GlobalState.application) == null
override fun isLocalNetworkPermissionGranted(): Boolean =
sdkInt < Build.VERSION_CODES.CINNAMON_BUN ||
ContextCompat.checkSelfPermission(
GlobalState.application,
Manifest.permission.ACCESS_LOCAL_NETWORK,
) == PackageManager.PERMISSION_GRANTED
override fun tile(): TileGateway? = flutterEngine?.plugin<TilePlugin>()?.let { plugin ->
object : TileGateway {
override fun handleStart() = plugin.handleStart()
@@ -113,6 +128,9 @@ internal object AndroidServiceStateHost : ServiceStateHost {
override fun requestNotificationPermission(callback: (Boolean) -> Unit) =
plugin.requestNotificationPermission(callback)
override fun requestLocalNetworkPermission(callback: (Boolean) -> Unit) =
plugin.requestLocalNetworkPermission(callback)
override fun prepareVpn(enable: Boolean, callback: (Boolean) -> Unit) =
plugin.prepareVpn(enable, callback)
@@ -136,7 +136,13 @@ internal class ServiceStateMachine(private val host: ServiceStateHost) {
}
val app = host.app()
if (app != null) {
app.requestNotificationPermission(launchRequest)
app.requestNotificationPermission { shouldStart ->
if (shouldStart) {
app.requestLocalNetworkPermission { launchRequest(true) }
} else {
launchRequest(false)
}
}
} else {
launchRequest(true)
}
@@ -246,7 +252,7 @@ internal class ServiceStateMachine(private val host: ServiceStateHost) {
return@transition true
}
mutableRunState.value = RunState.STARTING
val startedAtMillis = host.startService(options)
val startedAtMillis = host.startService(withLocalNetworkFallback(options))
if (startedAtMillis == 0L) {
mutableRunState.value = RunState.STOPPED
fail(request)
@@ -260,6 +266,17 @@ internal class ServiceStateMachine(private val host: ServiceStateHost) {
}
}
// system/mixed hand TCP to the kernel via the tun subnet, which Android 17 gates as local network.
private fun withLocalNetworkFallback(options: VpnOptions): VpnOptions {
if (!options.enable || options.stack !in KERNEL_TCP_STACKS ||
host.isLocalNetworkPermissionGranted()
) {
return options
}
host.showToast(sharedState.localNetworkTip)
return options.copy(stack = FALLBACK_STACK)
}
private suspend fun reconcileStopped() = transitionLock.withLock {
if (isRunningRequested() || runTimeMillis == 0L) {
return@withLock
@@ -322,6 +339,9 @@ internal class ServiceStateMachine(private val host: ServiceStateHost) {
}
internal companion object {
val KERNEL_TCP_STACKS = setOf("system", "mixed")
const val FALLBACK_STACK = "gvisor"
/**
* The Core init payload. The key spelling is a cross-language contract with the Go wrapper,
* not an implementation detail.
@@ -6,6 +6,8 @@ import com.google.gson.annotations.SerializedName
data class SharedState(
val startTip: String = "Starting VPN...",
val stopTip: String = "Stopping VPN...",
val localNetworkTip: String =
"Local network permission denied: using the gvisor stack, LAN is unreachable.",
val crashlytics: Boolean = true,
val currentProfileName: String = "FlClash",
val stopText: String = "Stop",
@@ -68,8 +68,14 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
private val requestInstalledAppsCallback = PendingCallback<Boolean>()
private val requestLocalNetworkCallback = PendingCallback<Boolean>()
private var isRequestingNotificationPermission = false
private var isRequestingLocalNetworkPermission = false
private var skipLocalNetworkPermissionRequest = false
private val gson = Gson()
private val packageResolver by lazy {
@@ -309,6 +315,42 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
requestNotificationCallback.resolve(shouldStart)
}
fun requestLocalNetworkPermission(callback: (Boolean) -> Unit) = onMainThread {
requestLocalNetworkCallback.replace(callback, supersededValue = false)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.CINNAMON_BUN || hasLocalNetworkPermission()) {
invokeRequestLocalNetworkCallback(true)
return@onMainThread
}
if (skipLocalNetworkPermissionRequest) {
invokeRequestLocalNetworkCallback(false)
return@onMainThread
}
if (isRequestingLocalNetworkPermission) {
return@onMainThread
}
val activity = activity
if (activity == null) {
invokeRequestLocalNetworkCallback(false)
return@onMainThread
}
isRequestingLocalNetworkPermission = true
ActivityCompat.requestPermissions(
activity,
arrayOf(Manifest.permission.ACCESS_LOCAL_NETWORK),
LOCAL_NETWORK_PERMISSION_REQUEST_CODE,
)
}
private fun hasLocalNetworkPermission(): Boolean = ContextCompat.checkSelfPermission(
GlobalState.application,
Manifest.permission.ACCESS_LOCAL_NETWORK,
) == PackageManager.PERMISSION_GRANTED
private fun invokeRequestLocalNetworkCallback(granted: Boolean) {
isRequestingLocalNetworkPermission = false
requestLocalNetworkCallback.resolve(granted)
}
private fun requestInstalledAppsPermission(callback: (Boolean) -> Unit) = onMainThread {
requestInstalledAppsCallback.replace(callback, supersededValue = false)
val activity = activity
@@ -390,6 +432,7 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
scope.cancel()
invokeVpnPrepareCallback(false)
invokeRequestNotificationCallback(false)
invokeRequestLocalNetworkCallback(false)
invokeRequestInstalledAppsCallback(false)
}
@@ -426,6 +469,7 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
detachFromActivity()
invokeVpnPrepareCallback(false)
invokeRequestNotificationCallback(false)
invokeRequestLocalNetworkCallback(false)
invokeRequestInstalledAppsCallback(false)
}
@@ -456,6 +500,15 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
true
}
LOCAL_NETWORK_PERMISSION_REQUEST_CODE -> {
skipLocalNetworkPermissionRequest = true
invokeRequestLocalNetworkCallback(
grantResults.isNotEmpty() &&
grantResults[0] == PackageManager.PERMISSION_GRANTED,
)
true
}
else -> false
}
@@ -463,5 +516,6 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
const val VPN_PERMISSION_REQUEST_CODE = 1001
const val NOTIFICATION_PERMISSION_REQUEST_CODE = 1002
const val INSTALLED_APPS_PERMISSION_REQUEST_CODE = 1003
const val LOCAL_NETWORK_PERMISSION_REQUEST_CODE = 1004
}
}
+7
View File
@@ -33,6 +33,13 @@ android {
}
}
// :app strips what it packages; Crashlytics symbolication needs the unstripped libraries to reach its merge.
packaging {
jniLibs {
keepDebugSymbols += "**/*.so"
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
+10 -10
View File
@@ -1,16 +1,16 @@
[versions]
firebaseBom = "34.15.0"
minSdk = "23"
targetSdk = "36"
compileSdk = "36"
firebaseBom = "34.19.0"
minSdk = "24"
targetSdk = "37"
compileSdk = "37"
ndkVersion = "28.2.13676358"
coreKtx = "1.17.0"
annotationJvm = "1.9.1"
coreSplashscreen = "1.0.1"
gson = "2.13.1"
smaliDexlib2 = "3.0.9"
coreKtx = "1.19.1"
annotationJvm = "1.11.0"
coreSplashscreen = "1.2.0"
gson = "2.14.0"
smaliDexlib2 = "3.0.10"
junit = "4.13.2"
kotlinxCoroutines = "1.10.2"
kotlinxCoroutines = "1.11.0"
[libraries]
androidx-core = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
+1 -1
View File
@@ -2,5 +2,5 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-all.zip
+3 -3
View File
@@ -19,10 +19,10 @@ pluginManagement {
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.0.1" apply false
id("org.jetbrains.kotlin.android") version "2.4.0" apply false
id("com.android.application") version "9.4.1" apply false
id("org.jetbrains.kotlin.android") version "2.4.20" apply false
id("com.google.gms.google-services") version "4.5.0" apply false
id("com.google.firebase.crashlytics") version "3.0.7" apply false
id("com.google.firebase.crashlytics") version "3.0.8" apply false
}
include(":app")
+67 -4
View File
@@ -18,7 +18,7 @@ import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
private fun vpnOptions(enable: Boolean = true) = VpnOptions(
private fun vpnOptions(enable: Boolean = true, stack: String = "gvisor") = VpnOptions(
enable = enable,
port = 7890,
ipv6 = false,
@@ -32,12 +32,12 @@ private fun vpnOptions(enable: Boolean = true) = VpnOptions(
allowBypass = false,
systemProxy = true,
bypassDomain = emptyList(),
stack = "gvisor",
stack = stack,
routeAddress = emptyList(),
)
private fun configuredState(enable: Boolean = true) = SharedState(
vpnOptions = vpnOptions(enable),
private fun configuredState(enable: Boolean = true, stack: String = "gvisor") = SharedState(
vpnOptions = vpnOptions(enable, stack),
setupParams = SetupParams(testUrl = "https://example.com", selectedMap = emptyMap()),
)
@@ -56,17 +56,24 @@ private class FakeTile : TileGateway {
private class FakeApp(
private val notificationGranted: Boolean = true,
private val localNetworkGranted: Boolean = true,
private val vpnGranted: Boolean = true,
private val holdVpnPreparation: Boolean = false,
) : AppGateway {
var beforeVpnPrepared: (() -> Unit)? = null
var cancelledPreparations = 0
var localNetworkRequests = 0
private var heldCallback: ((Boolean) -> Unit)? = null
override fun requestNotificationPermission(callback: (Boolean) -> Unit) =
callback(notificationGranted)
override fun requestLocalNetworkPermission(callback: (Boolean) -> Unit) {
localNetworkRequests++
callback(localNetworkGranted)
}
override fun prepareVpn(enable: Boolean, callback: (Boolean) -> Unit) {
beforeVpnPrepared?.invoke()
if (holdVpnPreparation) {
@@ -89,10 +96,12 @@ private class FakeHost(override val scope: CoroutineScope) : ServiceStateHost {
var setupResult: Result<String> = Result.success("")
var startResult = 1_700_000_000_000L
var vpnPermissionGranted = true
var localNetworkPermissionGranted = true
var vpnServiceActive = true
var tile: TileGateway? = null
var app: AppGateway? = null
var beforeStartService: (() -> Unit)? = null
var lastStartOptions: VpnOptions? = null
override var runTimeMillis = 0L
override val homeDirPath = "/data/user/0/com.follow.clash/files"
@@ -128,6 +137,8 @@ private class FakeHost(override val scope: CoroutineScope) : ServiceStateHost {
override fun isVpnPermissionGranted(): Boolean = vpnPermissionGranted
override fun isLocalNetworkPermissionGranted(): Boolean = localNetworkPermissionGranted
override fun tile(): TileGateway? = tile
override fun app(): AppGateway? = app
@@ -141,6 +152,7 @@ private class FakeHost(override val scope: CoroutineScope) : ServiceStateHost {
override suspend fun startService(options: VpnOptions): Long {
startCalls++
lastStartOptions = options
beforeStartService?.invoke()
runTimeMillis = startResult
return startResult
@@ -279,6 +291,57 @@ class ServiceStateMachineTest {
assertEquals(1, host.startCalls)
}
/** Android 17 drops the kernel hop system/mixed rely on until the permission is granted. */
@Test
fun `a denied local network permission falls back to the gvisor stack`() = runTest {
val host = FakeHost(backgroundScope)
host.localNetworkPermissionGranted = false
val machine = ServiceStateMachine(host)
machine.syncSharedState(configuredState(stack = "system"))
assertTrue(machine.requestStart().await())
assertEquals("gvisor", host.lastStartOptions?.stack)
assertTrue(host.toasts.contains(configuredState().localNetworkTip))
}
@Test
fun `a granted local network permission keeps the chosen stack`() = runTest {
val host = FakeHost(backgroundScope)
val machine = ServiceStateMachine(host)
machine.syncSharedState(configuredState(stack = "mixed"))
assertTrue(machine.requestStart().await())
assertEquals("mixed", host.lastStartOptions?.stack)
assertFalse(host.toasts.contains(configuredState().localNetworkTip))
}
@Test
fun `a proxy-only start keeps its stack without the local network permission`() = runTest {
val host = FakeHost(backgroundScope)
host.localNetworkPermissionGranted = false
val machine = ServiceStateMachine(host)
machine.syncSharedState(configuredState(enable = false, stack = "system"))
assertTrue(machine.requestStart().await())
assertEquals("system", host.lastStartOptions?.stack)
assertFalse(host.toasts.contains(configuredState().localNetworkTip))
}
@Test
fun `the local network prompt is asked once and never blocks the start`() = runTest {
val host = FakeHost(backgroundScope)
val app = FakeApp(localNetworkGranted = false)
host.app = app
host.localNetworkPermissionGranted = false
val machine = ServiceStateMachine(host)
machine.syncSharedState(configuredState(stack = "system"))
assertTrue(machine.requestStart().await())
assertEquals(1, app.localNetworkRequests)
assertEquals(1, host.startCalls)
assertEquals("gvisor", host.lastStartOptions?.stack)
}
@Test
fun `a stop request drives the service to STOPPED`() = runTest {
val host = FakeHost(backgroundScope)