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:
@@ -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.
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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>
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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
@@ -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
@@ -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
|
||||
|
||||
@@ -236,21 +236,40 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('truncates past the caption limit and links out', () {
|
||||
final text = renderTelegram(
|
||||
version(
|
||||
ChangelogVersion longVersion() => version(
|
||||
groups: [
|
||||
entryGroup(ChangelogType.feat, [
|
||||
for (var index = 0; index < 200; index++)
|
||||
('hash$index', null, 'A reasonably long changelog entry'),
|
||||
('hash$index', null, 'A reasonably long changelog entry & more'),
|
||||
]),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
test('truncates past the caption limit and links out', () {
|
||||
final text = renderTelegram(
|
||||
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('&', '&');
|
||||
|
||||
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 & more\n\n…'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+5
-1
@@ -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}'),
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user