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:
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user