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
+31
View File
@@ -0,0 +1,31 @@
# Flutter Upgrade Checklist
Read this before changing `FLUTTER_VERSION` in `.github/workflows/build.yaml` or `.fvmrc`. Each entry is a
workaround tied to a Flutter bug: what it does, where it lives, and what has to be true before it can go. Remove an
entry together with its workaround; add one whenever a new workaround is pinned to the SDK.
## Stable cadence
Flutter ships a stable release about once a quarter, three minor versions apart (3.41 in February, 3.44 in May, 3.47
in August 2026). The two minors in between (3.48, 3.49) are beta only, so a fix that lands on `main` reaches stable
in the next multiple, 3.50, expected around mid-November 2026. Hotfix releases (3.47.x) carry only cherry-picks
listed in the SDK's `CHANGELOG.md`.
## Impeller disabled on Intel macOS release builds
- **Bug:** [flutter/flutter#191538](https://github.com/flutter/flutter/issues/191538). From 3.47, when Impeller
became the default desktop renderer, Intel (x86_64) Macs flicker across the whole window while scrolling or
animating, and text can render garbled. Apple Silicon is not affected.
- **Fix upstream:** landed on `main` before 2026-08-25 and verified on `3.48.0-1.0.pre-404`; it is not in any
3.47.x hotfix (checked through 3.47.5).
- **Workaround:** the release job for `macos-15-intel` (`arch: amd64`) runs
`plutil -replace FLTEnableImpeller -bool NO macos/Runner/Info.plist` before `dart setup.dart`, so only the Intel
DMG falls back to Skia. The checked-in `Info.plist` does not set the key, so local builds and the arm64 DMG keep
Impeller. The macOS embedder reads the key at launch in `FlutterDartProject.mm` (`enableImpeller`), release
builds included.
- **Side effect:** Skia on Intel renders blur, shadows and text slightly differently; judge Intel-only visual reports
against Skia, not Impeller.
- **Remove when:** `FLUTTER_VERSION` moves to a stable that contains the fix (3.50 or later, or a 3.47.x whose
changelog lists #191538). Before dropping the step, build the Intel DMG without it and scroll the proxies and
dashboard pages on an Intel Mac with its integrated GPU driving the built-in display; an external display can
switch to the discrete GPU and hide the flicker.
+5 -3
View File
@@ -23,9 +23,11 @@ cask "flclash" do
app "FlClash.app"
postflight do
system_command "xattr",
args: ["-rd", "com.apple.quarantine", "#{appdir}/FlClash.app"]
postflight_steps do
run "/usr/bin/xattr",
args: ["-rd", "com.apple.quarantine", "{{appdir}}/FlClash.app"],
writable_paths: ["FlClash.app"],
writable_base: :appdir
end
uninstall quit: "com.follow.clash"
+92 -4
View File
@@ -6,6 +6,7 @@ on:
- '**'
tags:
- 'v*'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -13,19 +14,23 @@ concurrency:
env:
IS_STABLE: ${{ !contains(github.ref, '-') }}
FLUTTER_VERSION: '3.47.1'
FLUTTER_VERSION: '3.47.4'
GO_VERSION: '1.26.4'
NDK_VERSION: r28c
jobs:
dart:
name: Dart
# A private copy pays for its Actions minutes, so it runs the checks only when
# started by hand.
if: &checks github.event_name == 'workflow_dispatch' || !github.event.repository.private
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: true
- name: Test commit message hook
run: bash tool/check_commit_msg_test.sh
@@ -33,6 +38,12 @@ jobs:
- name: Test comment density hook
run: bash tool/check_comment_density_test.sh
- name: Setup Rust cache
uses: Swatinem/rust-cache@v2
with:
workspaces: plugins/rust_api/rust
cache-directories: .dart_tool/hooks_runner
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
@@ -49,9 +60,9 @@ jobs:
- name: Analyze
run: flutter analyze --no-fatal-infos
- name: Skip native build hooks
- name: Skip the Go core build hook
run: |
yq -i '.hooks.user_defines.setup.build_assets = false | .hooks.user_defines.rust_api.build_assets = false' pubspec.yaml
yq -i '.hooks.user_defines.setup.build_assets = false' pubspec.yaml
- name: Verify changelog
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')
@@ -65,6 +76,7 @@ jobs:
plugins:
name: Plugins
if: *checks
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -87,8 +99,75 @@ jobs:
dart analyze
dart test
desktop:
name: Desktop ${{ matrix.platform }}
if: *checks
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- platform: linux
os: ubuntu-latest
- platform: windows
os: windows-2022
- platform: macos
os: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: ${{ env.GO_VERSION }}
cache-dependency-path: core/go.sum
- name: Setup Rust cache
uses: Swatinem/rust-cache@v2
with:
key: desktop-${{ matrix.platform }}
workspaces: |
services/helper
plugins/rust_api/rust
cache-directories: .dart_tool/hooks_runner
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
- name: Install Linux build dependencies
if: matrix.platform == 'linux'
run: |
sudo apt-get update
sudo apt-get install -y ninja-build libgtk-3-dev libayatana-appindicator3-dev libsecret-1-dev
- name: Setup Xcode
if: matrix.platform == 'macos'
run: |
sudo xcode-select -s /Applications/Xcode_26.3.app || sudo xcode-select -s /Applications/Xcode_26.2.app
xcodebuild -version
- name: Enable git long paths (Windows)
if: matrix.platform == 'windows'
run: git config --global core.longpaths true
- name: Install dependencies
run: flutter pub get
# Compiles the desktop runners and the native plugin code that only the
# tagged release matrix built before; the bundle itself is discarded.
- name: Build debug bundle
run: flutter build ${{ matrix.platform }} --debug
go:
name: Go core
if: *checks
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -140,6 +219,7 @@ jobs:
android:
name: Android unit tests
if: *checks
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -190,6 +270,7 @@ jobs:
rust:
name: Rust
if: *checks
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -214,6 +295,7 @@ jobs:
windows-helper-test:
name: Windows helper test
if: *checks
runs-on: windows-2022
steps:
- name: Checkout
@@ -231,7 +313,8 @@ jobs:
run: cargo test --manifest-path services/helper/Cargo.toml --features windows-service
build:
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
# Releases publish where the release links point: the public repository.
if: github.repository == 'chen08209/FlClash' && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
needs: [ dart, plugins, go, android, rust, windows-helper-test ]
runs-on: ${{ matrix.os }}
strategy:
@@ -334,6 +417,11 @@ jobs:
flutter --version
flutter pub get
# See .agents/flutter-upgrade.md before bumping FLUTTER_VERSION.
- name: Disable Impeller on Intel macOS
if: matrix.platform == 'macos' && matrix.arch == 'amd64'
run: plutil -replace FLTEnableImpeller -bool NO macos/Runner/Info.plist
- name: Setup
env:
ANDROID_NDK_HOME: ${{ steps.build-ndk.outputs.ndk-path }}
+7
View File
@@ -0,0 +1,7 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="main.dart (safe mode)" type="FlutterRunConfigurationType" factoryName="Flutter">
<option name="additionalArgs" value="--dart-define-from-file env.json --dart-define=SAFE_MODE=true" />
<option name="filePath" value="$PROJECT_DIR$/lib/main.dart" />
<method v="2" />
</configuration>
</component>
+24
View File
@@ -0,0 +1,24 @@
[
{
"label": "Flutter App (With Env)",
"adapter": "Dart",
"type": "flutter",
"request": "launch",
"program": "lib/main.dart",
"cwd": "$ZED_WORKTREE_ROOT",
"toolArgs": ["--dart-define-from-file", "env.json"],
},
{
"label": "Flutter App (Safe Mode)",
"adapter": "Dart",
"type": "flutter",
"request": "launch",
"program": "lib/main.dart",
"cwd": "$ZED_WORKTREE_ROOT",
"toolArgs": [
"--dart-define-from-file",
"env.json",
"--dart-define=SAFE_MODE=true",
],
},
]
+21
View File
@@ -0,0 +1,21 @@
[
{
"label": "flutter run (With Env)",
"command": "flutter",
"args": ["run", "--dart-define-from-file", "env.json"],
"cwd": "$ZED_WORKTREE_ROOT",
"use_new_terminal": true,
},
{
"label": "flutter run (Safe Mode)",
"command": "flutter",
"args": [
"run",
"--dart-define-from-file",
"env.json",
"--dart-define=SAFE_MODE=true",
],
"cwd": "$ZED_WORKTREE_ROOT",
"use_new_terminal": true,
},
]
+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)
+2
View File
@@ -38,6 +38,8 @@ const sheetAppBarHeight = 68.0;
const watchExecution = false;
const safeModeBuild = bool.fromEnvironment('SAFE_MODE');
String _randomPipeId() {
final random = Random.secure();
return List.generate(
+1 -1
View File
@@ -1,6 +1,6 @@
[toolchain]
# native_toolchain_rust refuses a floating channel; bump this deliberately.
channel = "1.95.0"
channel = "1.98.1"
targets = [
"aarch64-apple-darwin",
"x86_64-apple-darwin",
@@ -124,9 +124,18 @@ class GoBuilder {
return env;
}
// AGP strips the copy it packages into the APK, and Crashlytics reads the
// unstripped one from the merged native libs, so Android keeps its symbols.
String _ldflags(Target target) => target.isLib
? config.goLdflags
.split(' ')
.where((flag) => flag != '-s' && flag != '-w')
.join(' ')
: config.goLdflags;
List<String> _buildArguments(Target target, {String? outFile}) => [
'build',
'-ldflags=${config.goLdflags}',
'-ldflags=${_ldflags(target)}',
'-tags=${config.tags}',
if (target.isLib) '-buildmode=c-shared',
if (outFile != null) ...['-o', outFile],
+18 -26
View File
@@ -285,10 +285,10 @@ packages:
dependency: transitive
description:
name: cupertino_ui
sha256: "7ed8ce4159d342eec4c65f4ea6eec57adaf9365404378541f38efc1da20a5b3d"
sha256: c23816577a1280cff409e989541767887011f8f1d8c1863a794ed308af46f2a9
url: "https://pub.dev"
source: hosted
version: "1.0.0"
version: "1.1.1"
dart_style:
dependency: transitive
description:
@@ -305,14 +305,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.14"
defer_pointer:
dependency: "direct main"
description:
name: defer_pointer
sha256: d69e6f8c1d0f052d2616cc1db3782e0ea73f42e4c6f6122fd1a548dfe79faf02
url: "https://pub.dev"
source: hosted
version: "0.0.2"
device_info_plus:
dependency: "direct main"
description:
@@ -543,10 +535,10 @@ packages:
dependency: "direct main"
description:
name: flutter_riverpod
sha256: "56e81e662e6e54e59b231da57e90ac0d06ac67d8e3f2273c129a37ae6282b40c"
sha256: "2b7f9d2a3c730ac1a98221b420cef966b990fca7ab546ede324642ca57a533e3"
url: "https://pub.dev"
source: hosted
version: "3.4.2"
version: "3.4.3"
flutter_rust_bridge:
dependency: transitive
description:
@@ -906,10 +898,10 @@ packages:
dependency: "direct main"
description:
name: material_ui
sha256: "4f3f38b9953df0a87d6bf5f21880029f77c47048487d5339410c39936be4683b"
sha256: "134ab2f0843545c4e661e4ba2753fe1bcf0753cb690d5a9ee4870638a40411e9"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
version: "1.4.0"
meta:
dependency: transitive
description:
@@ -1185,34 +1177,34 @@ packages:
dependency: "direct main"
description:
name: riverpod
sha256: "84351b3472a447f7b89f961f95c73287009d58c59d8cc1267425d995f78e50f1"
sha256: "484dfc873ea4c4f4240e5635444fa066f87b07862b7279ebd004a2b71fba4b7a"
url: "https://pub.dev"
source: hosted
version: "3.4.2"
version: "3.4.3"
riverpod_analyzer_utils:
dependency: transitive
description:
name: riverpod_analyzer_utils
sha256: "46a8dd0461080fde8f886f65f87580758dae8495e352f9ea6a09953abc3df7bd"
sha256: bb13cb3985815840cc103eda1027158358c5fdc00f308fc6f76574d0594107af
url: "https://pub.dev"
source: hosted
version: "1.0.0-dev.11"
version: "1.0.0-dev.12"
riverpod_annotation:
dependency: "direct main"
description:
name: riverpod_annotation
sha256: dd043c75eb3cea2654f5dca51234413ac04bd092ccc73e747fd73cae37ac5360
sha256: "322e56ad198928ef26ba240f85421bfff2493bd65665c887b1299e6386b6206d"
url: "https://pub.dev"
source: hosted
version: "4.0.6"
version: "4.0.7"
riverpod_generator:
dependency: "direct dev"
description:
name: riverpod_generator
sha256: "2e66b5691cf5d50d6a409904b0477e869e0ecbdd5ada1535d263a95d44439e75"
sha256: c94dcb8b60ff4805d2bb8cacca969c48237bcb563e2ab0b5afb50204470c7636
url: "https://pub.dev"
source: hosted
version: "4.0.8"
version: "4.0.9"
rust_api:
dependency: "direct main"
description:
@@ -1419,10 +1411,10 @@ packages:
dependency: transitive
description:
name: sqflite
sha256: "58a799e6ac17dd32fbab93813d39ed835a75ccc0f8f85b8955fe318c6712b082"
sha256: "54ad23e2a79c846b596b6c56cdd9c867ea58c288258402316b602f78df0cbfe0"
url: "https://pub.dev"
source: hosted
version: "2.4.3"
version: "2.4.4"
sqflite_android:
dependency: transitive
description:
@@ -1832,5 +1824,5 @@ packages:
source: git
version: "2.1.0"
sdks:
dart: ">=3.13.0 <4.0.0"
flutter: ">=3.44.0"
dart: ">=3.13.2 <4.0.0"
flutter: ">=3.47.2"
+4 -5
View File
@@ -1,7 +1,7 @@
name: fl_clash
description: A multi-platform proxy client based on ClashMeta, simple and easy to use, open-source and ad-free.
publish_to: 'none'
version: 0.8.98+2026091401
version: 0.8.99+2026092701
environment:
sdk: '>=3.10.0 <4.0.0'
@@ -15,7 +15,7 @@ hooks:
dependencies:
flutter:
sdk: flutter
material_ui: ^1.0.1
material_ui: ^1.4.0
intl: any
path_provider: ^2.1.5
path: ^1.9.0
@@ -59,10 +59,9 @@ dependencies:
device_info_plus: ^13.2.0
connectivity_plus: 7.2.0
screen_retriever: ^0.2.1
defer_pointer: ^0.0.2
flutter_riverpod: ^3.4.2
flutter_riverpod: ^3.4.3
riverpod_annotation: ^4.0.6
riverpod: ^3.4.2
riverpod: ^3.4.3
material_color_utilities: ^0.13.0
material_new_shapes: ^1.0.0
flutter_svg: ^2.1.0
+29 -10
View File
@@ -236,21 +236,40 @@ void main() {
);
});
ChangelogVersion longVersion() => version(
groups: [
entryGroup(ChangelogType.feat, [
for (var index = 0; index < 200; index++)
('hash$index', null, 'A reasonably long changelog entry & more'),
]),
],
);
test('truncates past the caption limit and links out', () {
final text = renderTelegram(
version(
groups: [
entryGroup(ChangelogType.feat, [
for (var index = 0; index < 200; index++)
('hash$index', null, 'A reasonably long changelog entry'),
]),
],
),
longVersion(),
moreUrl: 'https://example.com',
);
expect(text.length, lessThan(telegramLimit + 40));
expect(text, endsWith('https://example.com'));
expect(text.length, lessThan(telegramLimit + 200));
expect(text, endsWith('…\nhttps://example.com'));
});
test('counts visible text, not markup, against the limit', () {
final text = renderTelegram(longVersion());
final visible = text
.replaceAll(RegExp('<[^>]*>'), '')
.replaceAll('&amp;', '&');
expect(visible.length, greaterThan(telegramLimit - 60));
expect(visible.length, lessThanOrEqualTo(telegramLimit + 3));
});
test('ends on an ellipsis alone without a page to link to', () {
expect(
renderTelegram(longVersion()),
endsWith('• A reasonably long changelog entry &amp; more\n\n…'),
);
});
});
+5 -1
View File
@@ -123,9 +123,13 @@ int _render(String root, ArgResults command) {
final output = switch (rest.first) {
'release' => renderRelease(version),
// Only stable builds pass --tag, and only they get a release page; a
// prerelease caption already links its workflow run.
'telegram' => renderTelegram(
version,
moreUrl: 'https://github.com/$_repository/releases',
moreUrl: tag == null
? null
: 'https://github.com/$_repository/releases/tag/$tag',
),
_ => _fail('Unknown render target: ${rest.first}'),
};
+18 -12
View File
@@ -98,7 +98,7 @@ String escapeTelegramHtml(String value) => value
/// Telegram captions are capped at 1024 characters, so the text is truncated
/// with a pointer to the full notes instead of failing the upload.
String renderTelegram(ChangelogVersion version, {required String moreUrl}) {
String renderTelegram(ChangelogVersion version, {String? moreUrl}) {
final buffer = StringBuffer();
if (version.isEmpty) {
buffer.writeln('• ${escapeTelegramHtml(emptyVersionNote)}');
@@ -111,21 +111,27 @@ String renderTelegram(ChangelogVersion version, {required String moreUrl}) {
buffer.writeln();
}
final text = buffer.toString().trimRight();
if (text.length <= telegramLimit) {
if (_telegramVisibleLength(text) <= telegramLimit) {
return text;
}
final cut = text.substring(0, telegramLimit);
final lastBreak = cut.lastIndexOf('\n');
// Cut on a line break so the cut never lands inside a tag; the trailing
// pattern covers the one case that has no break to cut on, where the cut can
// still land halfway through an entity.
final kept = (lastBreak < 0 ? cut : cut.substring(0, lastBreak)).replaceAll(
RegExp(r'&[A-Za-z]*$|<[^>]*$'),
'',
);
return '$kept\n\n…\n$moreUrl';
final kept = StringBuffer();
var length = 0;
for (final line in text.split('\n')) {
length += _telegramVisibleLength(line) + 1;
if (length > telegramLimit) {
break;
}
kept.writeln(line);
}
final more = moreUrl == null ? '' : '\n$moreUrl';
return '${kept.toString().trimRight()}\n\n…$more';
}
int _telegramVisibleLength(String markup) => markup
.replaceAll(RegExp('<[^>]*>'), '')
.replaceAll(RegExp('&[a-z]+;'), '&')
.length;
/// Version headings stay the only `##` level; groups are bold lines because a
/// release carries a handful of entries and an `###` per group outweighs them.
String _heading(ChangelogVersion version) => version.date.isEmpty