Compare commits

...

1 Commits

Author SHA1 Message Date
chen08209 4e54189f49 Adjust android process
Fix some issues

Optimize more details
2026-07-28 13:21:48 +08:00
290 changed files with 16368 additions and 8495 deletions
+93 -17
View File
@@ -13,7 +13,8 @@ Android lib mode:
Desktop core mode:
- Go core runs as a separate process with `CGO_ENABLED=0`.
- Flutter communicates via JSON over socket, using a Unix socket on macOS/Linux and TCP on Windows.
- Flutter communicates through the `rust_api` local IPC server, using a Unix
domain socket on macOS/Linux and a named pipe on Windows.
- Dart-side implementation: `lib/core/service.dart` (`CoreService`).
`lib/core/controller.dart` (`CoreController`) selects the implementation based on platform. `lib/core/interface.dart` defines the shared `CoreHandlerInterface`.
@@ -21,7 +22,7 @@ Desktop core mode:
Key Go core files:
- `core/hub.go`: handler functions.
- `core/action.go`: dispatch.
- `core/method.go`: MethodChannel-style method-call dispatch and response envelopes.
- `core/lib.go`: CGO exports.
- `core/server.go`: socket server.
@@ -71,7 +72,14 @@ Each manager in `lib/manager/` handles a specific platform concern. Desktop-only
`lib/core/controller.dart` (`CoreController`) is a singleton facade over `CoreHandlerInterface`. Public methods delegate to the platform-specific interface, either Android FFI or desktop socket. It has an `@visibleForTesting` constructor and `resetInstance()` for test injection.
Business logic lives in Riverpod notifier classes in `lib/providers/action.dart`:
The shared core protocol uses `CoreMethodCall(method, arguments)` in both directions and `CoreMethodResponse(result, error)` for replies. Desktop calls include an `id` for concurrent request correlation; Android carries the same JSON envelope through its service `MethodChannel` and JNI bridge.
The envelope is the only JSON serialization layer: keep `arguments`, `result`, and event data as structured JSON values instead
of embedding pre-encoded JSON strings. Plain domain strings, such as country codes or provider file contents, remain strings.
`lib/providers/action.dart` is the public library entry point for action
providers. The Riverpod notifier implementations are split by responsibility
under `lib/providers/actions/` and joined to the entry point with `part`
directives, so consumers continue to import the same public API:
- `CommonAction`: update check and common UI operations.
- `SetupAction`: config setup and TUN management.
@@ -82,6 +90,7 @@ Business logic lives in Riverpod notifier classes in `lib/providers/action.dart`
- `ThemeAction`: theme state updates.
- `ProxiesAction`: group management and proxy selection.
- `ProfilesAction`: profile CRUD, auto-update, import.
- `GeoResourceAction`: geo resource updates and URL configuration.
## Platform Managers
@@ -110,10 +119,10 @@ Shared:
`setup.dart` is the release build orchestrator:
1. On Windows, pre-builds Go core via `dart run build_tool windows` and reads `core_sha256.json`.
2. Writes `env.json` (`APP_ENV`).
3. Passes SHA256 as `--dart-define=CORE_SHA256=$val`, embedded at compile time for Windows.
4. Activates `flutter_distributor` for packaging.
1. Writes `env.json` (`APP_ENV`).
2. Activates `flutter_distributor` for packaging.
3. Relies on the platform build hook to build the required Core artifacts before
the native application is linked.
Go core building is handled by `build_tool`, a standalone Dart CLI in `plugins/setup/buildkit/build_tool/`.
@@ -121,25 +130,72 @@ Platform build hooks inside `flutter build` trigger `build_tool` automatically:
- macOS: podspec script phase, `build_pod.sh`, `build_tool macos`.
- Linux: CMake include, `buildkit/cmake/buildkit.cmake`, `build_tool linux`.
- Windows: CMake include, `buildkit/cmake/buildkit.cmake`, `build_tool windows`. Debug passes `--dev` via `CMAKE_BUILD_TYPE`.
- Windows: CMake include, `buildkit/cmake/buildkit.cmake`, `build_tool windows`. CMake forwards the active configuration through `BUILDKIT_CONFIGURATION`.
- Android: Gradle include, `buildkit/gradle/plugin.gradle`, `build_tool android`.
Windows helper auth:
### Setup Build Harness Plugin
- Release: Core SHA256 is embedded in both the Flutter app and the Rust helper. The app pings the helper and verifies the token matches.
- Debug: The Rust helper skips token verification when built in debug mode, so `flutter run` works without the SHA256 flow.
`plugins/setup/` is a build-time Flutter plugin, not a runtime Dart or FFI API. Its plugin shape exists so Flutter's native
build graphs can run the Go/Rust build harness before platform consumers need the generated artifacts. Application code
must not import or call it.
`plugins/setup/` is an FFI plugin that exists only as a build harness. It carries no Dart API, only platform build hooks that trigger Go compilation. Windows builds also compile a Rust helper in `services/helper/` through `RustBuilder`.
Responsibilities are deliberately split:
Build configuration defaults live in `build_tool/lib/src/options.dart` and can be overridden via `build_config.yaml`.
- CocoaPods, Gradle, and CMake hooks schedule a lightweight check on every native build. They do not decide which Go or
Rust files are stale.
- `buildkit/build_tool/` owns target resolution, input fingerprinting, compilation, output copying, and cache validation.
- `core/` and `services/helper/` remain source owners; `libclash/` and Android `jniLibs`/header directories are generated
output locations.
- `setup.dart` remains the release/package orchestrator and does not pre-build
platform artifacts or pass Core integrity data into Dart.
Platform outputs remain explicit:
- Android builds the Go core as `c-shared`, then copies `libclash.so` and generated headers into the `:core` Android module.
- macOS and Linux build a standalone `FlClashCore` process used by the desktop socket integration.
- Windows builds `FlClashCore.exe` plus the Rust `FlClashHelperService.exe` privileged helper.
The hooks follow rust_api/Cargokit's phony-output scheduling pattern, but setup uses its own cache because it builds both a
Go core and, on Windows, a separate Rust helper. Per-target records live under `.dart_tool/setup_build_cache/v1/`:
- Go fingerprints cover the target-specific `go list -deps` inputs inside `core/` and `Clash.Meta`, module files, effective
build configuration, build-tool sources, target flags, Go environment/toolchain, and Android NDK compiler details.
- Windows helper fingerprints cover its Rust sources and manifests, Cargo/Rust
toolchains and flags, and the expected Core SHA256.
- A cache hit requires the fingerprint and every recorded output's path, size, and modification state to match. It exits
silently without Go/Cargo compilation, output copying, or Windows `taskkill`.
- Cache records are written only after a successful build and protected by per-target process/file locks. Missing outputs,
changed inputs, cache-schema changes, or `--force` rebuild only the affected target.
- `flutter clean` removes `.dart_tool`, so the next native build performs one full core rebuild. Manual builds can bypass
the cache with `make core-<platform> FORCE=1`.
This differs from `rust_api`: rust_api is a runtime Flutter Rust Bridge integration whose Cargokit hooks produce its native
FFI library, while setup is only the build and packaging bridge for FlClash's external core artifacts.
Windows helper integrity/version check:
- The build tool constructs the Core first, calculates its SHA256, and always
builds the Rust Helper with release hardening and that expected hash.
- Flutter does not embed or send the Core SHA256. Debug, Profile, and Release
builds use the same Helper protocol and may use TUN through the same flow.
- Ping is loopback-only and requires no request token. The Helper verifies the
fixed `FlClashCore.exe` beside it against its embedded SHA256 before reporting
readiness, and repeats the verification before every launch.
- Flutter creates a named pipe with a 128-bit random suffix and passes only that
address to the helper. The helper validates the address namespace, returns the
spawned Core PID, and Flutter matches it against the named-pipe peer PID.
- The Helper path and protocol version allow Flutter to replace stale service
registrations, while the Helper-owned Core check detects mismatched builds.
Build configuration defaults live in `build_tool/lib/src/options.dart` and can be overridden via a root `build_config.yaml`.
Architecture detection is automatic. The `--description` flag passed to `flutter_distributor` adds arch suffixes to artifact names, such as `FlClash-0.8.93-macos-arm64.dmg`.
## Local Plugins
- `setup`: build harness FFI plugin.
- `setup`: build-time harness for Go core artifacts and the Windows Rust helper; no runtime Dart API.
- `proxy`: system proxy configuration.
- `rust_api`: Flutter Rust Bridge FFI plugin.
- `rust_api`: runtime Flutter Rust Bridge FFI plugin built through Cargokit.
- `tray_manager`: system tray fork/customization.
- `wifi_ssid`: Wi-Fi SSID detection.
- `window_ext`: window extensions.
@@ -150,7 +206,27 @@ Architecture detection is automatic. The `--description` flag passed to `flutter
`services/helper/` is a Windows-only privileged helper for starting the core as admin and managing TUN. It is built with:
```bash
cargo build --release --features windows-service
make core-windows
```
It uses token-based auth with the Flutter app.
The build tool always compiles the Helper in Rust release mode after calculating
the SHA256 of the Core produced for the active Flutter configuration.
The helper owns its Windows Service Control Manager lifecycle through two elevated commands:
- `FlClashHelperService.exe install` stops and removes any stale registration, creates the auto-start service for the
current executable path, starts it, and waits for the running state.
- `FlClashHelperService.exe uninstall` stops the service, waits for shutdown, removes its registration, and is also used
by the Windows package uninstaller.
The Dart layer only launches the helper's `install` command through `ShellExecuteW`; it does not compose `sc.exe`,
`taskkill`, or `cmd.exe` command lines.
In every Flutter build mode it opens the fixed Core executable beside the Helper
without write/delete sharing, validates it against the SHA256 embedded only in
the Helper, and keeps that handle open through process creation. `/ping`
self-validates the Core before reporting readiness; `/start`, `/stop`, and
`/logs` remain loopback-only without request-token validation. The Helper accepts
only FlClash's random Core named-pipe namespace, returns the spawned PID, and
reports its executable path and protocol version from ping so stale
registrations are replaced.
+13 -2
View File
@@ -33,11 +33,15 @@ make core-macos ARCH=arm64
make core-android TARGET_PLATFORM=android-arm64
```
Core builds use setup's input fingerprint cache. Pass `FORCE=1` to bypass it,
for example `make core-macos ARCH=arm64 FORCE=1`.
The Makefile wraps `plugins/setup/buildkit/run_build_tool.sh`; prefer the `make` entry points unless debugging the build tool itself.
## Flutter Development
The project is pinned with FVM.
The project follows FVM's `stable` channel locally. Release CI pins an exact
Flutter version separately; see `.agents/project.md`.
```bash
fvm flutter pub get
@@ -95,7 +99,7 @@ Root `flutter test` only discovers the root package's `test/` directory by defau
## Verify
CI runs these in order:
The pull-request and release workflows run these root-package checks in order:
```bash
flutter pub get
@@ -104,3 +108,10 @@ flutter test --reporter expanded
```
Run `flutter analyze` locally before committing when practical.
The test job runs for pull requests and `v*` tag pushes. Release builds remain
tag-only. Root analysis excludes `plugins/**`, and root tests do not discover
nested plugin packages, so CI also validates local Flutter packages, the setup
build tool, the Go wrapper, and Rust components from their own package
directories. A separate Windows runner compiles and tests the helper's
`windows-service` feature before release builds can start.
+4 -2
View File
@@ -4,8 +4,10 @@ FlClash is a multi-platform proxy client based on ClashMeta (mihomo), built with
## Version Notes
- `.fvmrc` pins Flutter 3.35.7 for local development.
- CI uses Flutter 3.41.9. These may diverge; trust CI as the source of truth for release builds.
- `.fvmrc` follows the FVM `stable` channel for local development; it does not
pin an immutable Flutter version.
- Release CI pins Flutter 3.44.4. Local `stable` may diverge, so trust the CI
version as the source of truth for release builds.
- Dart SDK constraint: `>=3.8.0 <4.0.0`.
## Build Dependencies
+10
View File
@@ -21,8 +21,17 @@ Generated directories are excluded from analysis:
- `lib/**/generated/**`
- `plugins/**`
## Core API Safety
- Do not expose direct filesystem deletion APIs through Core or helper IPC; use
a scope-specific cleanup API instead.
## Testing Rules
The `core/` directory is excluded from automated test coverage. Do not add `*_test.go` files, coverage instrumentation,
or coverage collection for code under `core/`. Verify core protocol behavior through shared Dart contract tests under
`test/core/` and native platform build checks instead.
Use `CoreController.test(mock)` to inject a mocked `CoreHandlerInterface`. Call `CoreController.resetInstance()` in `tearDown` to clean up the singleton between tests.
Register fallback values for freezed params used with `any()` matchers.
@@ -39,6 +48,7 @@ When testing freezed models with nested objects, always round-trip through `json
Do not manually edit generated files under:
- `lib/l10n/l10n.dart`
- `lib/models/generated/`
- `lib/providers/generated/`
- `lib/database/generated/`
+4 -1
View File
@@ -28,6 +28,9 @@ Read `.agents/architecture.md` for the current core modes, manager stack, build
## Pitfalls
- Debug Windows helper auth differs from release token verification.
- Keep the Windows Helper protocol and Core SHA256 validation identical across
Flutter build modes; the Helper owns executable integrity checks.
- Do not expose direct filesystem deletion APIs through Core or helper IPC; use
a scope-specific cleanup API instead.
- `plugins/setup/` is a build harness, not a Dart API plugin.
- Build hooks can trigger Go or Rust compilation indirectly through Flutter platform builds.
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -euo pipefail
previous_tag="${1:-}"
output="${2:-release.md}"
: > "$output"
append_range() {
local range="$1"
git log --no-merges --pretty=format:'%B' "$range" |
awk '!/Update changelog/ && NF {print "- " $0 "\n"}' >> "$output"
}
current_tag=""
while IFS= read -r next_tag; do
if [[ -n "$current_tag" ]]; then
[[ "$current_tag" == "$previous_tag" ]] && break
append_range "$next_tag..$current_tag"
fi
current_tag="$next_tag"
done < <(git tag --merged HEAD --sort=-creatordate)
if [[ -n "$current_tag" && "$current_tag" != "$previous_tag" ]]; then
append_range "$current_tag"
fi
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
generator="$script_dir/generate_release_notes.sh"
temp_dir="$(mktemp -d)"
trap 'rm -rf "$temp_dir"' EXIT
repo="$temp_dir/repo"
git init --quiet --initial-branch=main "$repo"
git -C "$repo" config user.email "release-notes-test@example.com"
git -C "$repo" config user.name "Release notes test"
commit() {
local message="$1"
local date="$2"
GIT_AUTHOR_DATE="$date" GIT_COMMITTER_DATE="$date" \
git -C "$repo" commit --allow-empty --quiet --message "$message"
}
commit "Initial release" "2025-01-01T00:00:00Z"
git -C "$repo" tag v1.0.0
commit "First release change" "2025-01-02T00:00:00Z"
commit "Update changelog" "2025-01-03T00:00:00Z"
git -C "$repo" tag v1.1.0
git -C "$repo" branch feature
git -C "$repo" checkout --quiet feature
commit $'Feature branch change\n\nFeature detail' "2025-01-04T00:00:00Z"
git -C "$repo" checkout --quiet main
GIT_AUTHOR_DATE="2025-01-05T00:00:00Z" \
GIT_COMMITTER_DATE="2025-01-05T00:00:00Z" \
git -C "$repo" merge --no-ff --quiet --message "Merge feature" feature
git -C "$repo" tag v1.2.0
expected="$temp_dir/expected.md"
actual="$temp_dir/actual.md"
printf '%s\n' \
"- Feature branch change" \
"" \
"- Feature detail" \
"" \
"- First release change" \
"" > "$expected"
(
cd "$repo"
bash "$generator" v1.0.0 "$actual"
)
diff -u "$expected" "$actual"
printf '%s\n' "stale content" > "$actual"
(
cd "$repo"
bash "$generator" v1.2.0 "$actual"
)
[[ ! -s "$actual" ]]
(
cd "$repo"
bash "$generator" "" "$actual"
)
grep -q -- "- Initial release" "$actual"
+85 -26
View File
@@ -4,6 +4,7 @@ on:
push:
tags:
- 'v*'
pull_request:
env:
IS_STABLE: ${{ !contains(github.ref, '-') }}
FLUTTER_VERSION: '3.44.4'
@@ -18,6 +19,10 @@ jobs:
with:
submodules: recursive
- name: Test release notes generation
shell: bash
run: bash .github/scripts/generate_release_notes_test.sh
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
@@ -34,8 +39,73 @@ jobs:
- name: Run tests
run: flutter test --reporter expanded
- name: Validate local Flutter packages
shell: bash
run: |
for package in plugins/proxy plugins/wifi_ssid plugins/window_ext plugins/setup; do
(
cd "$package"
flutter pub get
flutter analyze --no-fatal-infos
)
done
- name: Run local plugin tests
shell: bash
run: |
(
cd plugins/proxy
flutter test --reporter expanded
)
(
cd plugins/wifi_ssid
flutter test --reporter expanded
)
- name: Validate setup build tool
working-directory: plugins/setup/buildkit/build_tool
run: |
dart pub get
dart analyze
dart test
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.26.4'
cache-dependency-path: core/go.sum
- name: Validate Go core wrapper
working-directory: core
env:
CGO_ENABLED: 0
run: |
go test .
go vet .
- name: Validate Rust components
run: |
cargo fmt --manifest-path services/helper/Cargo.toml -- --check
cargo test --manifest-path services/helper/Cargo.toml
cargo fmt --manifest-path plugins/rust_api/rust/Cargo.toml -- --check
cargo test --manifest-path plugins/rust_api/rust/Cargo.toml
windows-helper-test:
name: Windows helper test
runs-on: windows-2022
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Check format
run: cargo fmt --manifest-path services/helper/Cargo.toml -- --check
- name: Run Windows service tests
run: cargo test --manifest-path services/helper/Cargo.toml --features windows-service
build:
needs: [ test ]
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
needs: [ test, windows-helper-test ]
runs-on: ${{ matrix.os }}
strategy:
matrix:
@@ -149,18 +219,19 @@ jobs:
- name: Generate
run: |
last_ver=$(grep -m1 '^## ' CHANGELOG.md 2>/dev/null | sed 's/^## //')
tags=($(git tag --merged HEAD --sort=-creatordate))
temp="NEW_CHANGELOG.md" > "$temp"
mapfile -t tags < <(git tag --merged HEAD --sort=-creatordate)
temp="NEW_CHANGELOG.md"
: > "$temp"
for i in "${!tags[@]}"; do
curr="${tags[i]}"
[[ "$curr" == "$last_ver" ]] && break
prev="${tags[i+1]}"
range="${prev:+$prev..}$curr"
echo -e "## $curr\n" >> "$temp"
git log --no-merges --pretty=format:"%B" "$range" | \
awk '!/Update changelog/ && NF {print "- " $0 "\n"}' >> "$temp"
@@ -215,25 +286,13 @@ jobs:
merge-multiple: true
- name: Generate release.md
shell: bash
run: |
tags=($(git tag --merged HEAD --sort=-creatordate))
preTag=$(curl -s "https://api.github.com/repos/chen08209/FlClash/releases/latest" | \
sed -nE 's/.*"tag_name": "([^"]+)".*/\1/p')
[ -z "$preTag" ] && preTag=""
out="release.md" > "$out"
for i in "${!tags[@]}"; do
curr="${tags[i]}"
[[ "$curr" == "$preTag" ]] && break
prev="${tags[i+1]}"
range="${prev:+$prev..}$curr"
git log --no-merges --pretty=format:"%B" "$range" | \
awk '!/Update changelog/ && NF {print "- " $0 "\n"}' >> "$out"
done
pre_tag=$(
curl --silent "https://api.github.com/repos/chen08209/FlClash/releases/latest" |
sed -nE 's/.*"tag_name": "([^"]+)".*/\1/p'
)
bash .github/scripts/generate_release_notes.sh "$pre_tag" release.md
- name: Push to telegram
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
+2 -1
View File
@@ -74,4 +74,5 @@ devtools_options.yaml
# FVM Version Cache
.fvm/
.fvmrc
.fvmrc
/coverage/lcov.info
+3 -1
View File
@@ -4,6 +4,7 @@ PLATFORM ?= macos
BUILDKIT := plugins/setup/buildkit/run_build_tool.sh
ARCH_ARG := $(if $(ARCH),--arch $(ARCH),)
TARGET_PLATFORM_ARG := $(if $(TARGET_PLATFORM),--target-platform $(TARGET_PLATFORM),)
FORCE_ARG := $(if $(filter 1 true yes,$(FORCE)),--force,)
.PHONY: help submodules core core-macos core-linux core-windows core-android
@@ -13,12 +14,13 @@ help:
@echo 'make core-macos ARCH=arm64'
@echo 'make core-android ARCH=arm64'
@echo 'make core-android TARGET_PLATFORM=android-arm64'
@echo 'make core-macos FORCE=1 # bypass setup build cache'
submodules:
git submodule update --init --recursive
core:
bash $(BUILDKIT) $(PLATFORM) $(ARCH_ARG) $(TARGET_PLATFORM_ARG)
bash $(BUILDKIT) $(PLATFORM) $(ARCH_ARG) $(TARGET_PLATFORM_ARG) $(FORCE_ARG)
core-macos:
$(MAKE) core PLATFORM=macos
+17 -17
View File
@@ -15,21 +15,20 @@ val localProperties = Properties().apply {
}
}
val mStoreFile: File = file("keystore.jks")
val mStorePassword: String? = localProperties.getProperty("storePassword")
val mKeyAlias: String? = localProperties.getProperty("keyAlias")
val mKeyPassword: String? = localProperties.getProperty("keyPassword")
val isRelease =
mStoreFile.exists() && mStorePassword != null && mKeyAlias != null && mKeyPassword != null
val releaseStoreFile = file("keystore.jks")
val releaseStorePassword = localProperties.getProperty("storePassword")
val releaseKeyAlias = localProperties.getProperty("keyAlias")
val releaseKeyPassword = localProperties.getProperty("keyPassword")
val hasReleaseSigning = releaseStoreFile.exists() &&
releaseStorePassword != null &&
releaseKeyAlias != null &&
releaseKeyPassword != null
android {
namespace = "com.follow.clash"
compileSdk = libs.versions.compileSdk.get().toInt()
ndkVersion = libs.versions.ndkVersion.get()
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
@@ -44,12 +43,12 @@ android {
}
signingConfigs {
if (isRelease) {
if (hasReleaseSigning) {
create("release") {
storeFile = mStoreFile
storePassword = mStorePassword
keyAlias = mKeyAlias
keyPassword = mKeyPassword
storeFile = releaseStoreFile
storePassword = releaseStorePassword
keyAlias = releaseKeyAlias
keyPassword = releaseKeyPassword
}
}
}
@@ -69,7 +68,7 @@ android {
release {
isMinifyEnabled = true
isShrinkResources = true
if (isRelease) {
if (hasReleaseSigning) {
signingConfig = signingConfigs.getByName("release")
} else {
signingConfig = signingConfigs.getByName("debug")
@@ -77,7 +76,8 @@ android {
}
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
@@ -93,10 +93,10 @@ flutter {
source = "../.."
}
dependencies {
implementation(project(":service"))
implementation(project(":common"))
implementation(project(":core"))
implementation(libs.core.splashscreen)
implementation(libs.gson)
implementation(libs.smali.dexlib2) {
+2 -2
View File
@@ -1,4 +1,4 @@
-keep class com.follow.clash.models.**{ *; }
-keep class com.follow.clash.models.** { *; }
-keep class com.follow.clash.service.models.**{ *; }
-keep class com.follow.clash.service.models.** { *; }
@@ -1,9 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<application
android:icon="@mipmap/ic_launcher"
android:label="FlClash Debug"
+9 -8
View File
@@ -11,10 +11,12 @@
<uses-feature
android:name="android.software.leanback"
android:required="false" />
<uses-feature
android:name="android.hardware.wifi"
android:required="false" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
@@ -23,15 +25,14 @@
tools:ignore="QueryAllPackagesPermission" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.POST_PROMOTED_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<application
android:name=".Application"
android:name=".FlClashApplication"
android:banner="@mipmap/ic_banner"
android:hardwareAccelerated="true"
android:icon="@mipmap/ic_launcher"
android:label="FlClash">
android:label="@string/app_name">
<activity
android:name=".MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
@@ -68,7 +69,7 @@
</activity>
<activity
android:name=".TempActivity"
android:name=".QuickActionActivity"
android:excludeFromRecents="true"
android:exported="true"
android:theme="@style/TransparentTheme">
@@ -90,7 +91,7 @@
android:name=".TileService"
android:exported="true"
android:icon="@drawable/ic"
android:label="FlClash"
android:label="@string/app_name"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
@@ -101,7 +102,7 @@
</service>
<receiver
android:name=".BroadcastReceiver"
android:name=".ServiceBroadcastReceiver"
android:enabled="true"
android:exported="true"
android:permission="${applicationId}.permission.RECEIVE_BROADCASTS">
@@ -115,4 +116,4 @@
android:name="flutterEmbedding"
android:value="2" />
</application>
</manifest>
</manifest>
@@ -1,29 +0,0 @@
package com.follow.clash
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.follow.clash.common.BroadcastAction
import com.follow.clash.common.GlobalState
import com.follow.clash.common.action
import kotlinx.coroutines.launch
class BroadcastReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
BroadcastAction.SERVICE_CREATED.action -> {
GlobalState.log("Receiver service created")
GlobalState.launch {
State.handleStartServiceAction()
}
}
BroadcastAction.SERVICE_DESTROYED.action -> {
GlobalState.log("Receiver service destroyed")
GlobalState.launch {
State.handleStopServiceAction()
}
}
}
}
}
@@ -17,37 +17,32 @@ import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.util.concurrent.TimeUnit
import kotlin.coroutines.resume
private const val ICON_TTL_DAYS = 1L
val Application.sharedState: SharedState
get() {
try {
val sp = getSharedPreferences("FlutterSharedPreferences", MODE_PRIVATE)
val res = sp.getString("flutter.sharedState", "")
return Gson().fromJson(res, SharedState::class.java)
} catch (_: Exception) {
return SharedState()
}
get() = try {
val preferences = getSharedPreferences("FlutterSharedPreferences", MODE_PRIVATE)
val json = preferences.getString("flutter.sharedState", null)
Gson().fromJson(json, SharedState::class.java) ?: SharedState()
} catch (_: Exception) {
SharedState()
}
private var lastToast: Toast? = null
fun Application.showToast(text: String?) {
if (text.isNullOrEmpty()) return
Handler(Looper.getMainLooper()).post {
lastToast?.cancel()
lastToast = Toast.makeText(this, text, Toast.LENGTH_LONG).apply {
show()
}
}
}
suspend fun PackageManager.getPackageIconPath(packageName: String): String =
@@ -55,8 +50,7 @@ suspend fun PackageManager.getPackageIconPath(packageName: String): String =
val cacheDir = GlobalState.application.cacheDir
val iconDir = File(cacheDir, "icons").apply { mkdirs() }
return@withContext try {
val pkgInfo = getPackageInfo(packageName, 0)
val lastUpdateTime = pkgInfo.lastUpdateTime
val lastUpdateTime = getPackageInfo(packageName, 0).lastUpdateTime
val iconFile = File(iconDir, "${packageName}_${lastUpdateTime}.webp")
if (iconFile.exists() && !isExpired(iconFile)) {
return@withContext iconFile.absolutePath
@@ -80,14 +74,11 @@ private suspend fun saveDrawableToFile(drawable: Drawable, file: File) {
drawable.toBitmap(width = 128, height = 128)
}
try {
val format = when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> {
Bitmap.CompressFormat.WEBP_LOSSY
}
else -> {
Bitmap.CompressFormat.WEBP
}
@Suppress("DEPRECATION")
val format = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
Bitmap.CompressFormat.WEBP_LOSSY
} else {
Bitmap.CompressFormat.WEBP
}
FileOutputStream(file).use { fos ->
bitmap.compress(format, 90, fos)
@@ -103,48 +94,12 @@ private fun isExpired(file: File): Boolean {
return age > TimeUnit.DAYS.toMillis(ICON_TTL_DAYS)
}
suspend fun <T> MethodChannel.awaitResult(
method: String, arguments: Any? = null
): T? = withContext(Dispatchers.Main) {
suspendCancellableCoroutine { continuation ->
invokeMethod(method, arguments, object : MethodChannel.Result {
override fun success(result: Any?) {
@Suppress("UNCHECKED_CAST") continuation.resume(result as T?)
}
override fun error(code: String, message: String?, details: Any?) {
continuation.resume(null)
}
override fun notImplemented() {
continuation.resume(null)
}
})
}
}
inline fun <reified T : FlutterPlugin> FlutterEngine.plugin(): T? {
return plugins.get(T::class.java) as T?
}
fun <T> MethodChannel.invokeMethodOnMainThread(
method: String, arguments: Any? = null, callback: ((Result<T>) -> Unit)? = null
) {
fun MethodChannel.invokeMethodOnMainThread(method: String, arguments: Any? = null) {
Handler(Looper.getMainLooper()).post {
invokeMethod(method, arguments, object : MethodChannel.Result {
override fun success(result: Any?) {
@Suppress("UNCHECKED_CAST") callback?.invoke(Result.success(result as T))
}
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
val exception = Exception("MethodChannel error: $errorCode - $errorMessage")
callback?.invoke(Result.failure(exception))
}
override fun notImplemented() {
val exception = NotImplementedError("Method not implemented: $method")
callback?.invoke(Result.failure(exception))
}
})
invokeMethod(method, arguments)
}
}
@@ -4,8 +4,7 @@ import android.app.Application
import android.content.Context
import com.follow.clash.common.GlobalState
class Application : Application() {
class FlClashApplication : Application() {
override fun attachBaseContext(base: Context?) {
super.attachBaseContext(base)
GlobalState.init(this)
@@ -1,37 +1,22 @@
package com.follow.clash
import android.os.Bundle
import com.follow.clash.common.GlobalState
import com.follow.clash.plugins.AppPlugin
import com.follow.clash.plugins.ServicePlugin
import com.follow.clash.plugins.TilePlugin
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
class MainActivity : FlutterActivity(),
CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
}
class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
flutterEngine.plugins.add(AppPlugin())
flutterEngine.plugins.add(ServicePlugin())
flutterEngine.plugins.add(TilePlugin())
State.flutterEngine = flutterEngine
ServiceState.attachFlutterEngine(flutterEngine)
}
override fun onDestroy() {
GlobalState.launch {
Service.setEventListener(null)
}
State.flutterEngine = null
flutterEngine?.let(ServiceState::detachFlutterEngine)
super.onDestroy()
}
}
}
@@ -0,0 +1,28 @@
package com.follow.clash
import android.app.Activity
import android.os.Bundle
import androidx.core.content.pm.ShortcutManagerCompat
import com.follow.clash.common.GlobalState
import com.follow.clash.common.QuickAction
import com.follow.clash.common.action
import kotlinx.coroutines.launch
class QuickActionActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
when (intent.action) {
QuickAction.START.action -> GlobalState.launch { ServiceState.handleStartAction() }
QuickAction.STOP.action -> GlobalState.launch { ServiceState.handleStopAction() }
QuickAction.TOGGLE.action -> {
ShortcutManagerCompat.reportShortcutUsed(this, SHORTCUT_ID)
GlobalState.launch { ServiceState.handleToggleAction() }
}
}
finish()
}
private companion object {
const val SHORTCUT_ID = "toggle"
}
}
@@ -1,187 +0,0 @@
package com.follow.clash
import com.follow.clash.common.GlobalState
import com.follow.clash.common.ServiceDelegate
import com.follow.clash.common.formatString
import com.follow.clash.common.intent
import com.follow.clash.service.IAckInterface
import com.follow.clash.service.ICallbackInterface
import com.follow.clash.service.IEventInterface
import com.follow.clash.service.IRemoteInterface
import com.follow.clash.service.IResultInterface
import com.follow.clash.service.IVoidInterface
import com.follow.clash.service.RemoteService
import com.follow.clash.service.models.NotificationParams
import com.follow.clash.service.models.VpnOptions
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
object Service {
private val delegate by lazy {
ServiceDelegate<IRemoteInterface>(
RemoteService::class.intent, ::handleServiceDisconnected
) {
IRemoteInterface.Stub.asInterface(it)
}
}
var onServiceDisconnected: ((String) -> Unit)? = null
private fun handleServiceDisconnected(message: String) {
onServiceDisconnected?.let {
it(message)
}
}
fun bind() {
delegate.bind()
}
fun unbind() {
delegate.unbind()
}
suspend fun invokeAction(data: String, cb: ((result: String) -> Unit)?): Result<Unit> {
val res = mutableListOf<ByteArray>()
return delegate.useService {
it.invokeAction(
data, object : ICallbackInterface.Stub() {
override fun onResult(
result: ByteArray?, isSuccess: Boolean, ack: IAckInterface?
) {
res.add(result ?: byteArrayOf())
ack?.onAck()
if (isSuccess) {
cb?.let { cb ->
cb(res.formatString())
}
}
}
})
}
}
suspend fun quickSetup(
initParamsString: String,
setupParamsString: String,
onStarted: (() -> Unit)?,
onResult: ((result: String) -> Unit)?,
): Result<Unit> {
val res = mutableListOf<ByteArray>()
return delegate.useService {
it.quickSetup(
initParamsString,
setupParamsString,
object : ICallbackInterface.Stub() {
override fun onResult(
result: ByteArray?, isSuccess: Boolean, ack: IAckInterface?
) {
res.add(result ?: byteArrayOf())
ack?.onAck()
if (isSuccess) {
onResult?.let { cb ->
cb(res.formatString())
}
}
}
},
object : IVoidInterface.Stub() {
override fun invoke() {
onStarted?.let { onStarted ->
onStarted()
}
}
}
)
}
}
suspend fun setEventListener(
cb: ((result: String?) -> Unit)?
): Result<Unit> {
val results = HashMap<String, MutableList<ByteArray>>()
return delegate.useService {
it.setEventListener(
when (cb != null) {
true -> object : IEventInterface.Stub() {
override fun onEvent(
id: String, data: ByteArray?, isSuccess: Boolean, ack: IAckInterface?
) {
if (results[id] == null) {
results[id] = mutableListOf()
}
results[id]?.add(data ?: byteArrayOf())
ack?.onAck()
if (isSuccess) {
cb(results[id]?.formatString())
results.remove(id)
}
}
}
false -> null
})
}
}
suspend fun updateNotificationParams(
params: NotificationParams
): Result<Unit> {
return delegate.useService {
it.updateNotificationParams(params)
}
}
suspend fun setCrashlytics(
enable: Boolean
): Result<Unit> {
return delegate.useService {
it.setCrashlytics(enable)
}
}
private suspend fun awaitIResultInterface(
block: (IResultInterface) -> Unit
): Long = suspendCancellableCoroutine { continuation ->
val callback = object : IResultInterface.Stub() {
override fun onResult(time: Long) {
if (continuation.isActive) {
continuation.resume(time)
}
}
}
try {
block(callback)
} catch (e: Exception) {
GlobalState.log("awaitIResultInterface $e")
if (continuation.isActive) {
continuation.resumeWithException(e)
}
}
}
suspend fun startService(options: VpnOptions, runTime: Long): Long {
return delegate.useService {
awaitIResultInterface { callback ->
it.startService(options, runTime, callback)
}
}.getOrNull() ?: 0L
}
suspend fun stopService(): Long {
return delegate.useService {
awaitIResultInterface { callback ->
it.stopService(callback)
}
}.getOrNull() ?: 0L
}
suspend fun getRunTime(): Long {
return delegate.useService {
it.runTime
}.getOrNull() ?: 0L
}
}
@@ -0,0 +1,58 @@
package com.follow.clash
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Handler
import android.os.Looper
import com.follow.clash.common.BroadcastAction
import com.follow.clash.common.GlobalState
import com.follow.clash.common.action
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.launch
class ServiceBroadcastReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val action = intent?.action ?: return
val pendingResult = goAsync()
val finished = AtomicBoolean(false)
val timeout = Runnable {
if (finished.compareAndSet(false, true)) {
GlobalState.log("Broadcast handling timed out: $action")
pendingResult.finish()
}
}
mainHandler.postDelayed(timeout, BROADCAST_TIMEOUT_MILLIS)
GlobalState.launch {
try {
handleAction(action)
} catch (error: Exception) {
GlobalState.log("Unable to handle service broadcast $action: $error")
} finally {
mainHandler.removeCallbacks(timeout)
if (finished.compareAndSet(false, true)) {
pendingResult.finish()
}
}
}
}
private suspend fun handleAction(action: String) {
when (action) {
BroadcastAction.SERVICE_CREATED.action -> {
GlobalState.log("Background service created")
ServiceState.handleStartAction()
}
BroadcastAction.SERVICE_DESTROYED.action -> {
GlobalState.log("Background service destroyed")
ServiceState.handleStopAction()
}
}
}
companion object {
private const val BROADCAST_TIMEOUT_MILLIS = 9_000L
private val mainHandler = Handler(Looper.getMainLooper())
}
}
@@ -0,0 +1,210 @@
package com.follow.clash
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import com.follow.clash.common.GlobalState
import com.follow.clash.common.intent
import com.follow.clash.core.Core
import com.follow.clash.service.ManagedService
import com.follow.clash.service.ProxyService
import com.follow.clash.service.ServiceConfig
import com.follow.clash.service.VpnService
import com.follow.clash.service.models.VpnOptions
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlin.coroutines.resume
object ServiceController {
private val lock = Mutex()
private var binding: ManagedServiceBinding? = null
@Volatile
private var runTimeMillis = 0L
suspend fun unbind() = lock.withLock {
clearBinding()
}
private fun clearBinding() {
binding?.unbind()
binding = null
}
fun invokeMethod(data: String, callback: ((String) -> Unit)?): Result<Unit> = runCatching {
Core.invokeMethod(data) { result ->
callback?.invoke(result.orEmpty())
}
}
suspend fun quickSetup(
initParams: String,
setupParams: String,
): Result<String> = runCatching {
suspendCancellableCoroutine { continuation ->
Core.quickSetup(initParams, setupParams) { result ->
continuation.resume(result.orEmpty())
}
}
}
fun setEventListener(callback: ((String?) -> Unit)?): Result<Unit> = runCatching {
Core.updateEventListener(callback)
}
suspend fun start(options: VpnOptions, previousRunTimeMillis: Long): Long = lock.withLock {
ServiceConfig.updateVpnOptions(options)
val nextIntent = if (options.enable) {
VpnService::class.intent
} else {
ProxyService::class.intent
}
if (binding?.component != nextIntent.component) {
clearBinding()
lateinit var nextBinding: ManagedServiceBinding
nextBinding = ManagedServiceBinding(nextIntent) { message ->
handleServiceDisconnected(nextBinding, message)
}
binding = nextBinding
nextBinding.bind().onFailure { error ->
GlobalState.log("Unable to bind background service: $error")
clearBinding()
return@withLock 0L
}
}
val currentBinding = binding ?: return@withLock 0L
val result = currentBinding.useService { service -> service.start() }
if (result.isFailure) {
GlobalState.log("Unable to start background service: ${result.exceptionOrNull()}")
clearBinding()
return@withLock 0L
}
runTimeMillis = previousRunTimeMillis.takeIf { it != 0L }
?: System.currentTimeMillis()
runTimeMillis
}
suspend fun stop(): Long = lock.withLock {
binding?.useService { service -> service.stop() }
?.onFailure { error ->
GlobalState.log("Unable to stop background service: $error")
}
clearBinding()
runTimeMillis = 0L
runTimeMillis
}
fun getRunTimeMillis(): Long = runTimeMillis
private fun handleServiceDisconnected(
disconnectedBinding: ManagedServiceBinding,
message: String,
) {
GlobalState.launch {
lock.withLock {
if (binding !== disconnectedBinding) {
return@withLock
}
GlobalState.log("Background service disconnected: $message")
clearBinding()
runTimeMillis = 0L
}
}
}
}
private class ManagedServiceBinding(
private val intent: Intent,
private val onDisconnected: (String) -> Unit,
) : ServiceConnection {
val component: ComponentName?
get() = intent.component
private val serviceState = MutableStateFlow<Result<ManagedService>?>(null)
@Volatile
private var isBound = false
suspend fun bind(): Result<Unit> = runCatching {
withContext(Dispatchers.Main.immediate) {
serviceState.value = null
isBound = GlobalState.application.bindService(
intent,
this@ManagedServiceBinding,
Context.BIND_AUTO_CREATE,
)
check(isBound) { "bindService() failed" }
}
}
suspend fun <R> useService(
timeoutMillis: Long = 5_000,
block: suspend (ManagedService) -> R,
): Result<R> = runCatching {
withTimeout(timeoutMillis) {
val service = serviceState.filterNotNull().first().getOrThrow()
withContext(Dispatchers.Default) {
block(service)
}
}
}
fun unbind() {
serviceState.value = null
if (!isBound) return
isBound = false
Handler(Looper.getMainLooper()).post {
runCatching {
GlobalState.application.unbindService(this)
}.onFailure { error ->
GlobalState.log("Unable to unbind background service: $error")
}
}
}
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
runCatching {
when (binder) {
is VpnService.LocalBinder -> binder.service
is ProxyService.LocalBinder -> binder.service
null -> error("Binder is empty")
else -> error("Unsupported service binder: ${binder.javaClass.name}")
}
}.onSuccess { service ->
serviceState.value = Result.success(service)
}.onFailure { error ->
disconnect(error.message.orEmpty())
}
}
override fun onServiceDisconnected(name: ComponentName?) {
disconnect("Service disconnected")
}
override fun onBindingDied(name: ComponentName?) {
disconnect("Service binding died")
}
override fun onNullBinding(name: ComponentName?) {
disconnect("Service returned an empty binder")
}
private fun disconnect(message: String) {
serviceState.value = Result.failure(IllegalStateException(message))
onDisconnected(message)
}
}
@@ -0,0 +1,284 @@
package com.follow.clash
import android.net.VpnService
import com.follow.clash.common.GlobalState
import com.follow.clash.models.SharedState
import com.follow.clash.plugins.AppPlugin
import com.follow.clash.plugins.TilePlugin
import com.follow.clash.service.ServiceConfig
import com.follow.clash.service.models.NotificationParams
import com.follow.clash.service.models.VpnOptions
import com.google.gson.Gson
import io.flutter.embedding.engine.FlutterEngine
import java.util.concurrent.atomic.AtomicReference
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlin.coroutines.resume
enum class RunState {
STARTED,
STARTING,
STOPPING,
STOPPED,
}
private const val MISSING_CONFIG_MESSAGE = "No configuration found."
private const val INVALID_CONFIG_MESSAGE = "Invalid configuration."
private const val VPN_PERMISSION_MESSAGE = "VPN permission required."
private const val START_FAILED_MESSAGE = "Failed to start service."
object ServiceState {
private val transitionLock = Mutex()
private val startPreparationLock = Mutex()
private val mutableRunState = MutableStateFlow(RunState.STOPPED)
private val latestRequest = AtomicReference(RunRequest(running = false))
@Volatile
private var sharedState = SharedState()
@Volatile
private var flutterEngine: FlutterEngine? = null
val runState = mutableRunState.asStateFlow()
var runTimeMillis = 0L
private set
private val appPlugin: AppPlugin?
get() = flutterEngine?.plugin<AppPlugin>()
private val tilePlugin: TilePlugin?
get() = flutterEngine?.plugin<TilePlugin>()
fun attachFlutterEngine(engine: FlutterEngine) {
flutterEngine = engine
}
fun detachFlutterEngine(engine: FlutterEngine) {
if (flutterEngine === engine) {
flutterEngine = null
}
}
suspend fun handleToggleAction() {
if (isRunningRequested()) {
handleStopAction()
} else {
handleStartAction()
}
}
suspend fun refresh() = transitionLock.withLock {
runTimeMillis = ServiceController.getRunTimeMillis()
mutableRunState.value = if (runTimeMillis == 0L) RunState.STOPPED else RunState.STARTED
}
suspend fun handleStartAction() {
if (isRunningRequested()) {
return
}
if (flutterEngine != null) {
tilePlugin?.handleStart()
return
}
loadPreferencesAndStart()
}
suspend fun handleStopAction() {
if (!isRunningRequested()) {
return
}
if (flutterEngine != null) {
tilePlugin?.handleStop()
return
}
GlobalState.application.showToast(sharedState.stopTip)
requestStop().await()
}
fun requestStart(): Deferred<Boolean> {
val request = createRequest(running = true)
val result = CompletableDeferred<Boolean>()
val launchRequest = {
GlobalState.launch {
result.complete(
runCatching { start(request) }
.onFailure { error ->
GlobalState.log("Unable to process service start request: $error")
fail(request)
}
.getOrDefault(false),
)
}
Unit
}
appPlugin?.requestNotificationPermission(launchRequest) ?: launchRequest()
return result
}
fun requestStop(): Deferred<Boolean> {
val request = createRequest(running = false)
val result = CompletableDeferred<Boolean>()
GlobalState.launch {
result.complete(
runCatching { stop(request) }
.onFailure { error ->
GlobalState.log("Unable to process service stop request: $error")
}
.getOrDefault(false),
)
}
return result
}
fun syncSharedState(state: SharedState) {
sharedState = state
applySharedState()
}
private suspend fun loadPreferencesAndStart() {
sharedState = GlobalState.application.sharedState
if (sharedState.setupParams == null || sharedState.vpnOptions == null) {
GlobalState.application.showToast(MISSING_CONFIG_MESSAGE)
return
}
if (setupCore()) {
if (!requestStart().await()) {
GlobalState.application.showToast(START_FAILED_MESSAGE)
}
}
}
private fun applySharedState() {
GlobalState.setCrashlytics(sharedState.crashlytics)
ServiceConfig.updateNotificationParams(
NotificationParams(
title = sharedState.currentProfileName,
stopText = sharedState.stopText,
onlyStatisticsProxy = sharedState.onlyStatisticsProxy,
),
)
}
private suspend fun setupCore(): Boolean {
applySharedState()
GlobalState.application.showToast(sharedState.startTip)
val initParams = Gson().toJson(
mapOf(
"home-dir" to GlobalState.application.filesDir.path,
"version" to android.os.Build.VERSION.SDK_INT,
),
)
val setupParams = Gson().toJson(sharedState.setupParams)
return ServiceController.quickSetup(
initParams,
setupParams,
).fold(
onSuccess = { message ->
if (message.isEmpty()) {
true
} else {
GlobalState.log("Unable to set up core: $message")
showConfigError(message)
false
}
},
onFailure = { error ->
GlobalState.log("Unable to set up core: $error")
showConfigError(error.message)
false
},
)
}
private fun showConfigError(message: String?) {
GlobalState.application.showToast(
message?.takeIf { it.isNotBlank() } ?: INVALID_CONFIG_MESSAGE,
)
}
private suspend fun start(request: RunRequest): Boolean = startPreparationLock.withLock {
if (!isCurrent(request)) {
return@withLock false
}
val options = sharedState.vpnOptions
if (options == null) {
fail(request)
return@withLock false
}
if (!prepareVpn(options)) {
if (appPlugin == null && isCurrent(request)) {
GlobalState.application.showToast(VPN_PERMISSION_MESSAGE)
}
fail(request)
return@withLock false
}
if (!isCurrent(request)) {
return@withLock false
}
transitionLock.withLock transition@{
if (!isCurrent(request)) {
return@transition false
}
if (runState.value == RunState.STARTED && runTimeMillis != 0L) {
return@transition true
}
mutableRunState.value = RunState.STARTING
runTimeMillis = ServiceController.start(options, runTimeMillis)
mutableRunState.value =
if (runTimeMillis == 0L) RunState.STOPPED else RunState.STARTED
if (runTimeMillis == 0L) {
fail(request)
return@transition false
}
isCurrent(request)
}
}
private suspend fun stop(request: RunRequest): Boolean = transitionLock.withLock {
if (!isCurrent(request)) {
return@withLock false
}
if (runState.value == RunState.STOPPED && runTimeMillis == 0L) {
return@withLock true
}
mutableRunState.value = RunState.STOPPING
runTimeMillis = ServiceController.stop()
mutableRunState.value = RunState.STOPPED
isCurrent(request)
}
private suspend fun prepareVpn(options: VpnOptions): Boolean {
val plugin = appPlugin
?: return !options.enable || VpnService.prepare(GlobalState.application) == null
return suspendCancellableCoroutine { continuation ->
plugin.prepareVpn(options.enable) { granted ->
if (continuation.isActive) {
continuation.resume(granted)
}
}
}
}
private fun createRequest(running: Boolean): RunRequest =
RunRequest(running).also(latestRequest::set)
private fun isRunningRequested(): Boolean = latestRequest.get().running
private fun isCurrent(request: RunRequest): Boolean = latestRequest.get() === request
private fun fail(request: RunRequest) {
latestRequest.compareAndSet(request, RunRequest(running = false))
}
private class RunRequest(
val running: Boolean,
)
}
@@ -1,205 +0,0 @@
package com.follow.clash
import android.net.VpnService
import com.follow.clash.common.GlobalState
import com.follow.clash.models.SharedState
import com.follow.clash.plugins.AppPlugin
import com.follow.clash.plugins.TilePlugin
import com.follow.clash.service.models.NotificationParams
import com.google.gson.Gson
import io.flutter.embedding.engine.FlutterEngine
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
enum class RunState {
START, PENDING, STOP
}
object State {
val runLock = Mutex()
var runTime: Long = 0
var sharedState: SharedState = SharedState()
val runStateFlow: MutableStateFlow<RunState> = MutableStateFlow(RunState.STOP)
var flutterEngine: FlutterEngine? = null
val appPlugin: AppPlugin?
get() = flutterEngine?.plugin<AppPlugin>()
val tilePlugin: TilePlugin?
get() = flutterEngine?.plugin<TilePlugin>()
suspend fun handleToggleAction() {
var action: (suspend () -> Unit)?
runLock.withLock {
action = when (runStateFlow.value) {
RunState.PENDING -> null
RunState.START -> ::handleStopServiceAction
RunState.STOP -> ::handleStartServiceAction
}
}
action?.invoke()
}
suspend fun handleSyncState() {
runLock.withLock {
try {
Service.bind()
runTime = Service.getRunTime()
val runState = when (runTime == 0L) {
true -> RunState.STOP
false -> RunState.START
}
runStateFlow.tryEmit(runState)
} catch (_: Exception) {
runStateFlow.tryEmit(RunState.STOP)
}
}
}
suspend fun handleStartServiceAction() {
runLock.withLock {
if (runStateFlow.value != RunState.STOP) {
return
}
tilePlugin?.handleStart()
if (flutterEngine != null) {
return
}
startServiceWithPref()
}
}
suspend fun handleStopServiceAction() {
runLock.withLock {
if (runStateFlow.value != RunState.START) {
return
}
tilePlugin?.handleStop()
if (flutterEngine != null) {
return
}
GlobalState.application.showToast(sharedState.stopTip)
handleStopService()
}
}
fun handleStartService() {
val appPlugin = flutterEngine?.plugin<AppPlugin>()
if (appPlugin != null) {
appPlugin.requestNotificationsPermission {
startService()
}
return
}
startService()
}
private fun startServiceWithPref() {
GlobalState.launch {
runLock.withLock {
if (runStateFlow.value != RunState.STOP) {
return@launch
}
sharedState = GlobalState.application.sharedState
setupAndStart()
}
}
}
suspend fun syncState() {
GlobalState.setCrashlytics(sharedState.crashlytics)
Service.updateNotificationParams(
NotificationParams(
title = sharedState.currentProfileName,
stopText = sharedState.stopText,
onlyStatisticsProxy = sharedState.onlyStatisticsProxy
)
)
Service.setCrashlytics(sharedState.crashlytics)
}
private suspend fun setupAndStart() {
Service.bind()
syncState()
GlobalState.application.showToast(sharedState.startTip)
val initParams = mutableMapOf<String, Any>()
initParams["home-dir"] = GlobalState.application.filesDir.path
initParams["version"] = android.os.Build.VERSION.SDK_INT
val initParamsString = Gson().toJson(initParams)
val setupParamsString = Gson().toJson(sharedState.setupParams)
Service.quickSetup(
initParamsString,
setupParamsString,
onStarted = {
startService()
},
onResult = {
if (it.isNotEmpty()) {
GlobalState.application.showToast(it)
}
},
)
}
private fun startService() {
GlobalState.launch {
runLock.withLock {
if (runStateFlow.value != RunState.STOP) {
return@launch
}
try {
runStateFlow.tryEmit(RunState.PENDING)
val options = sharedState.vpnOptions ?: return@launch
appPlugin?.let {
it.prepare(options.enable) {
runTime = Service.startService(options, runTime)
runStateFlow.tryEmit(RunState.START)
}
} ?: run {
val intent = VpnService.prepare(GlobalState.application)
if (intent != null) {
return@launch
}
runTime = Service.startService(options, runTime)
runStateFlow.tryEmit(RunState.START)
}
} finally {
if (runStateFlow.value == RunState.PENDING) {
runStateFlow.tryEmit(RunState.STOP)
}
}
}
}
}
fun handleStopService() {
GlobalState.launch {
runLock.withLock {
if (runStateFlow.value != RunState.START) {
return@launch
}
try {
runStateFlow.tryEmit(RunState.PENDING)
runTime = Service.stopService()
runStateFlow.tryEmit(RunState.STOP)
} finally {
if (runStateFlow.value == RunState.PENDING) {
runStateFlow.tryEmit(RunState.START)
}
}
}
}
}
}
@@ -1,37 +0,0 @@
package com.follow.clash
import android.app.Activity
import android.os.Bundle
import com.follow.clash.common.QuickAction
import com.follow.clash.common.action
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
class TempActivity : Activity(),
CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
when (intent.action) {
QuickAction.START.action -> {
launch {
State.handleStartServiceAction()
}
}
QuickAction.STOP.action -> {
launch {
State.handleStopServiceAction()
}
}
QuickAction.TOGGLE.action -> {
launch {
State.handleToggleAction()
}
}
}
finish()
}
}
@@ -3,7 +3,6 @@ package com.follow.clash
import android.annotation.SuppressLint
import android.os.Build
import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import com.follow.clash.common.QuickAction
import com.follow.clash.common.quickIntent
import com.follow.clash.common.toPendingIntent
@@ -13,49 +12,50 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
class TileService : TileService() {
class TileService : android.service.quicksettings.TileService() {
private var scope: CoroutineScope? = null
private fun updateTile(runState: RunState) {
if (qsTile != null) {
qsTile.state = when (runState) {
RunState.START -> Tile.STATE_ACTIVE
RunState.PENDING -> Tile.STATE_UNAVAILABLE
RunState.STOP -> Tile.STATE_INACTIVE
}
qsTile.updateTile()
}
}
override fun onStartListening() {
super.onStartListening()
scope?.cancel()
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
scope?.launch {
State.handleSyncState()
State.runStateFlow.collect {
updateTile(it)
scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate).also { scope ->
scope.launch {
ServiceState.refresh()
ServiceState.runState.collect(::updateTile)
}
}
}
@SuppressLint("StartActivityAndCollapseDeprecated")
private fun handleToggle() {
val intent = QuickAction.TOGGLE.quickIntent
val pendingIntent = intent.toPendingIntent
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startActivityAndCollapse(pendingIntent)
} else {
@Suppress("DEPRECATION") startActivityAndCollapse(intent)
}
}
override fun onClick() {
super.onClick()
handleToggle()
openQuickAction()
}
override fun onStopListening() {
scope?.cancel()
scope = null
super.onStopListening()
}
}
private fun updateTile(runState: RunState) {
qsTile?.apply {
state = when (runState) {
RunState.STARTED -> Tile.STATE_ACTIVE
RunState.STARTING, RunState.STOPPING -> Tile.STATE_UNAVAILABLE
RunState.STOPPED -> Tile.STATE_INACTIVE
}
updateTile()
}
}
@SuppressLint("StartActivityAndCollapseDeprecated")
private fun openQuickAction() {
val intent = QuickAction.TOGGLE.quickIntent
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startActivityAndCollapse(intent.toPendingIntent)
} else {
@Suppress("DEPRECATION")
startActivityAndCollapse(intent)
}
}
}
@@ -1,6 +1,6 @@
package com.follow.clash.models
data class Package(
data class InstalledPackage(
val packageName: String,
val label: String,
val system: Boolean,
@@ -0,0 +1,174 @@
package com.follow.clash.packages
import android.Manifest
import android.content.pm.ApplicationInfo
import android.content.pm.ComponentInfo
import android.content.pm.PackageManager
import android.os.Build
import com.android.tools.smali.dexlib2.dexbacked.DexBackedDexFile
import com.follow.clash.models.InstalledPackage
import java.io.File
import java.util.zip.ZipFile
internal class PackageResolver(
private val packageManager: PackageManager,
private val appPackageName: String,
) {
val installedPackages: List<InstalledPackage> by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
loadPackages()
}
fun getChinaPackageNames(): List<String> = installedPackages
.map { it.packageName }
.filter(::isChinaPackage)
private fun loadPackages(): List<InstalledPackage> {
val flags = PackageManager.GET_PERMISSIONS
val packages = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
packageManager.getInstalledPackages(
PackageManager.PackageInfoFlags.of(flags.toLong()),
)
} else {
@Suppress("DEPRECATION")
packageManager.getInstalledPackages(flags)
}
return packages.asSequence()
.filter { info ->
info.packageName != appPackageName && info.packageName != ANDROID_PACKAGE_NAME
}
.map { info ->
InstalledPackage(
packageName = info.packageName,
label = info.applicationInfo?.loadLabel(packageManager)?.toString()
?: info.packageName,
system = info.applicationInfo?.let { applicationInfo ->
applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM != 0
} == true,
internet = info.requestedPermissions
?.contains(Manifest.permission.INTERNET) == true,
lastUpdateTime = info.lastUpdateTime,
)
}.toList()
}
private fun isChinaPackage(packageName: String): Boolean {
if (SKIPPED_PREFIXES.any { packageName == it || packageName.startsWith("$it.") }) {
return false
}
if (packageName.matches(CHINA_PACKAGE_REGEX)) {
return true
}
return runCatching {
val packageInfo = getPackageInfo(packageName)
packageInfo.componentNames().any { it.matches(CHINA_PACKAGE_REGEX) } ||
packageInfo.applicationInfo?.publicSourceDir?.let(::scanArchive) == true
}.getOrDefault(false)
}
private fun getPackageInfo(packageName: String) = if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
) {
packageManager.getPackageInfo(
packageName,
PackageManager.PackageInfoFlags.of(PACKAGE_INFO_FLAGS.toLong()),
)
} else {
@Suppress("DEPRECATION")
packageManager.getPackageInfo(packageName, PACKAGE_INFO_FLAGS)
}
private fun android.content.pm.PackageInfo.componentNames(): Sequence<String> = sequence {
yieldAll(services.orEmpty().asSequence().map(ComponentInfo::name))
yieldAll(activities.orEmpty().asSequence().map(ComponentInfo::name))
yieldAll(receivers.orEmpty().asSequence().map(ComponentInfo::name))
yieldAll(providers.orEmpty().asSequence().map(ComponentInfo::name))
}
private fun scanArchive(sourcePath: String): Boolean = ZipFile(File(sourcePath)).use { archive ->
if (archive.entries().asSequence().any { it.name.startsWith("firebase-") }) {
return false
}
archive.entries().asSequence()
.filter { entry ->
entry.name.startsWith("classes") && entry.name.endsWith(".dex")
}.any { entry ->
if (entry.size > MAX_DEX_SIZE_BYTES) {
return@any true
}
val dexFile = archive.getInputStream(entry).buffered().use { input ->
DexBackedDexFile.fromInputStream(null, input)
}
dexFile.classes.any { clazz ->
clazz.type
.removeSurrounding("L", ";")
.replace('/', '.')
.replace('$', '.')
.matches(CHINA_PACKAGE_REGEX)
}
}
}
private companion object {
const val ANDROID_PACKAGE_NAME = "android"
const val MAX_DEX_SIZE_BYTES = 15_000_000L
val PACKAGE_INFO_FLAGS = PackageManager.GET_ACTIVITIES or
PackageManager.GET_SERVICES or
PackageManager.GET_RECEIVERS or
PackageManager.GET_PROVIDERS
val SKIPPED_PREFIXES = listOf(
"com.google",
"com.android.chrome",
"com.android.vending",
"com.microsoft",
"com.apple",
"com.zhiliaoapp.musically",
)
val CHINA_PACKAGE_REGEX = listOf(
"com.tencent",
"com.alibaba",
"com.umeng",
"com.qihoo",
"com.ali",
"com.alipay",
"com.amap",
"com.sina",
"com.weibo",
"com.vivo",
"com.xiaomi",
"com.huawei",
"com.taobao",
"com.secneo",
"s.h.e.l.l",
"com.stub",
"com.kiwisec",
"com.secshell",
"com.wrapper",
"cn.securitystack",
"com.mogosec",
"com.secoen",
"com.netease",
"com.mx",
"com.qq.e",
"com.baidu",
"com.bytedance",
"com.bugly",
"com.miui",
"com.oppo",
"com.coloros",
"com.iqoo",
"com.meizu",
"com.gionee",
"cn.nubia",
"com.oplus",
"andes.oplus",
"com.unionpay",
"cn.wps",
).joinToString("|", prefix = "(", postfix = ").*") { prefix ->
Regex.escape(prefix)
}.toRegex()
}
}
@@ -1,11 +1,10 @@
package com.follow.clash.plugins
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.app.ActivityManager
import android.content.Intent
import android.content.pm.ApplicationInfo
import android.content.pm.ComponentInfo
import android.content.pm.PackageManager
import android.net.VpnService
import android.os.Build
@@ -18,17 +17,15 @@ import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.core.net.toUri
import com.android.tools.smali.dexlib2.dexbacked.DexBackedDexFile
import com.follow.clash.R
import com.follow.clash.common.Components
import com.follow.clash.common.GlobalState
import com.follow.clash.common.QuickAction
import com.follow.clash.common.quickIntent
import com.follow.clash.getPackageIconPath
import com.follow.clash.models.Package
import com.follow.clash.packages.PackageResolver
import com.follow.clash.showToast
import com.google.gson.Gson
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
@@ -37,93 +34,39 @@ import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.Result
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.lang.ref.WeakReference
import java.util.zip.ZipFile
class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware {
companion object {
const val VPN_PERMISSION_REQUEST_CODE = 1001
const val NOTIFICATION_PERMISSION_REQUEST_CODE = 1002
}
private var activityRef: WeakReference<Activity>? = null
private var activity: Activity? = null
private lateinit var channel: MethodChannel
private lateinit var scope: CoroutineScope
private var vpnPrepareCallback: (suspend () -> Unit)? = null
private var vpnPrepareCallback: ((Boolean) -> Unit)? = null
private var requestNotificationCallback: (() -> Unit)? = null
private val packages = mutableListOf<Package>()
private var isRequestingNotificationPermission = false
private val skipPrefixList = listOf(
"com.google",
"com.android.chrome",
"com.android.vending",
"com.microsoft",
"com.apple",
"com.zhiliaoapp.musically", // Banned by China
)
private val gson = Gson()
private val chinaAppPrefixList = listOf(
"com.tencent",
"com.alibaba",
"com.umeng",
"com.qihoo",
"com.ali",
"com.alipay",
"com.amap",
"com.sina",
"com.weibo",
"com.vivo",
"com.xiaomi",
"com.huawei",
"com.taobao",
"com.secneo",
"s.h.e.l.l",
"com.stub",
"com.kiwisec",
"com.secshell",
"com.wrapper",
"cn.securitystack",
"com.mogosec",
"com.secoen",
"com.netease",
"com.mx",
"com.qq.e",
"com.baidu",
"com.bytedance",
"com.bugly",
"com.miui",
"com.oppo",
"com.coloros",
"com.iqoo",
"com.meizu",
"com.gionee",
"cn.nubia",
"com.oplus",
"andes.oplus",
"com.unionpay",
"cn.wps"
)
private val chinaAppRegex by lazy {
("(" + chinaAppPrefixList.joinToString("|").replace(".", "\\.") + ").*").toRegex()
private val packageResolver by lazy {
PackageResolver(
GlobalState.application.packageManager,
GlobalState.application.packageName,
)
}
private var isBlockNotification: Boolean = false
private var skipNotificationPermissionRequest = false
override fun onMethodCall(call: MethodCall, result: Result) {
when (call.method) {
"moveTaskToBack" -> {
activityRef?.get()?.moveTaskToBack(true)
activity?.moveTaskToBack(true)
result.success(true)
}
@@ -134,19 +77,24 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
}
"initShortcuts" -> {
initShortcuts(call.arguments as String)
result.success(true)
val label = call.arguments as? String
if (label == null) {
result.error("INVALID_ARGUMENT", "Shortcut label must be a string", null)
} else {
initShortcuts(label)
result.success(true)
}
}
"getPackages" -> {
scope.launch {
result.success(getPackagesToJson())
scope.launch(Dispatchers.IO) {
result.success(gson.toJson(packageResolver.installedPackages))
}
}
"getChinaPackageNames" -> {
scope.launch {
result.success(getChinaPackageNames())
scope.launch(Dispatchers.IO) {
result.success(gson.toJson(packageResolver.getChinaPackageNames()))
}
}
@@ -156,7 +104,7 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
"tip" -> {
val message = call.argument<String>("message")
tip(message)
GlobalState.application.showToast(message)
result.success(true)
}
@@ -172,6 +120,10 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
result.success(openAppSettings())
}
"didCrashOnPreviousExecution" -> {
result.success(GlobalState.didCrashOnPreviousExecution())
}
else -> {
result.notImplemented()
}
@@ -197,32 +149,32 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
IconCompat.createWithResource(
GlobalState.application,
R.mipmap.ic_launcher_round,
)
),
)
setIntent(QuickAction.TOGGLE.quickIntent)
build()
}
ShortcutManagerCompat.setDynamicShortcuts(
GlobalState.application, listOf(shortcut)
GlobalState.application,
listOf(shortcut),
)
}
private fun tip(message: String?) {
GlobalState.application.showToast(message)
}
private fun isBatteryOptimizationDisabled(): Boolean {
val powerManager = getSystemService(GlobalState.application, PowerManager::class.java)
return powerManager?.isIgnoringBatteryOptimizations(GlobalState.application.packageName)
?: false
}
@SuppressLint("BatteryLife")
private fun openBatteryOptimizationSettings(): Boolean {
// VPN continuity is the user-requested core function, so the direct exemption is intentional.
val activity = activity ?: return false
return try {
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = "package:${GlobalState.application.packageName}".toUri()
}
activityRef?.get()?.startActivity(intent)
activity.startActivity(intent)
true
} catch (_: Exception) {
false
@@ -230,11 +182,12 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
}
private fun openAppSettings(): Boolean {
val activity = activity ?: return false
return try {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = "package:${GlobalState.application.packageName}".toUri()
}
activityRef?.get()?.startActivity(intent)
activity.startActivity(intent)
true
} catch (_: Exception) {
false
@@ -246,170 +199,75 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
val am = getSystemService(GlobalState.application, ActivityManager::class.java)
val task = am?.appTasks?.firstOrNull {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
it.taskInfo.taskId == activityRef?.get()?.taskId
it.taskInfo.taskId == activity?.taskId
} else {
it.taskInfo.id == activityRef?.get()?.taskId
it.taskInfo.id == activity?.taskId
}
}
when (value) {
true -> task?.setExcludeFromRecents(value)
false -> task?.setExcludeFromRecents(value)
null -> task?.setExcludeFromRecents(false)
}
task?.setExcludeFromRecents(value ?: false)
}
private fun getPackages(): List<Package> {
val packageManager = GlobalState.application.packageManager
if (packages.isNotEmpty()) return packages
packageManager?.getInstalledPackages(PackageManager.GET_META_DATA or PackageManager.GET_PERMISSIONS)
?.filter {
it.packageName != GlobalState.application.packageName && it.packageName != "android"
}?.map {
Package(
packageName = it.packageName,
label = it.applicationInfo?.loadLabel(packageManager).toString(),
system = (it.applicationInfo?.flags?.and(ApplicationInfo.FLAG_SYSTEM)) != 0,
lastUpdateTime = it.lastUpdateTime,
internet = it.requestedPermissions?.contains(Manifest.permission.INTERNET) == true
)
}?.let { packages.addAll(it) }
return packages
}
private suspend fun getPackagesToJson(): String {
return withContext(Dispatchers.Default) {
Gson().toJson(getPackages())
}
}
private suspend fun getChinaPackageNames(): String {
return withContext(Dispatchers.Default) {
val packages: List<String> =
getPackages().map { it.packageName }.filter { isChinaPackage(it) }
Gson().toJson(packages)
}
}
fun requestNotificationsPermission(callBack: () -> Unit) {
requestNotificationCallback = callBack
fun requestNotificationPermission(callback: () -> Unit) {
requestNotificationCallback?.invoke()
requestNotificationCallback = callback
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val permission = ContextCompat.checkSelfPermission(
GlobalState.application, Manifest.permission.POST_NOTIFICATIONS
GlobalState.application,
Manifest.permission.POST_NOTIFICATIONS,
)
if (permission == PackageManager.PERMISSION_GRANTED || isBlockNotification) {
if (permission == PackageManager.PERMISSION_GRANTED || skipNotificationPermissionRequest) {
invokeRequestNotificationCallback()
return
}
activityRef?.get()?.let {
if (isRequestingNotificationPermission) {
return
}
isRequestingNotificationPermission = true
activity?.let {
ActivityCompat.requestPermissions(
it,
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
NOTIFICATION_PERMISSION_REQUEST_CODE
NOTIFICATION_PERMISSION_REQUEST_CODE,
)
}
} ?: invokeRequestNotificationCallback()
return
} else {
invokeRequestNotificationCallback()
}
invokeRequestNotificationCallback()
}
fun invokeRequestNotificationCallback() {
private fun invokeRequestNotificationCallback() {
isRequestingNotificationPermission = false
requestNotificationCallback?.invoke()
requestNotificationCallback = null
}
fun prepare(needPrepare: Boolean, callBack: (suspend () -> Unit)) {
vpnPrepareCallback = callBack
fun prepareVpn(needPrepare: Boolean, callback: (Boolean) -> Unit) {
vpnPrepareCallback = callback
if (!needPrepare) {
invokeVpnPrepareCallback()
invokeVpnPrepareCallback(true)
return
}
val intent = VpnService.prepare(GlobalState.application)
if (intent != null) {
activityRef?.get()?.startActivityForResult(intent, VPN_PERMISSION_REQUEST_CODE)
val activity = activity
if (activity == null) {
invokeVpnPrepareCallback(false)
} else {
@Suppress("DEPRECATION")
activity.startActivityForResult(intent, VPN_PERMISSION_REQUEST_CODE)
}
return
}
invokeVpnPrepareCallback()
invokeVpnPrepareCallback(true)
}
fun invokeVpnPrepareCallback() {
GlobalState.launch {
vpnPrepareCallback?.invoke()
vpnPrepareCallback = null
}
}
@Suppress("DEPRECATION")
private fun isChinaPackage(packageName: String): Boolean {
val packageManager = GlobalState.application.packageManager ?: return false
skipPrefixList.forEach {
if (packageName == it || packageName.startsWith("$it.")) return false
}
val packageManagerFlags =
PackageManager.MATCH_UNINSTALLED_PACKAGES or PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES or PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS
if (packageName.matches(chinaAppRegex)) {
return true
}
try {
val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
packageManager.getPackageInfo(
packageName, PackageManager.PackageInfoFlags.of(packageManagerFlags.toLong())
)
} else {
packageManager.getPackageInfo(
packageName, packageManagerFlags
)
}
mutableListOf<ComponentInfo>().apply {
packageInfo.services?.let { addAll(it) }
packageInfo.activities?.let { addAll(it) }
packageInfo.receivers?.let { addAll(it) }
packageInfo.providers?.let { addAll(it) }
}.forEach {
if (it.name.matches(chinaAppRegex)) return true
}
packageInfo.applicationInfo?.publicSourceDir?.let {
ZipFile(File(it)).use {
for (packageEntry in it.entries()) {
if (packageEntry.name.startsWith("firebase-")) return false
}
for (packageEntry in it.entries()) {
if (!(packageEntry.name.startsWith("classes") && packageEntry.name.endsWith(
".dex"
))
) {
continue
}
if (packageEntry.size > 15000000) {
return true
}
val input = it.getInputStream(packageEntry).buffered()
val dexFile = try {
DexBackedDexFile.fromInputStream(null, input)
} catch (e: Exception) {
return false
}
for (clazz in dexFile.classes) {
val clazzName =
clazz.type.substring(1, clazz.type.length - 1).replace("/", ".")
.replace("$", ".")
if (clazzName.matches(chinaAppRegex)) return true
}
}
}
}
} catch (_: Exception) {
return false
}
return false
private fun invokeVpnPrepareCallback(granted: Boolean) {
vpnPrepareCallback?.invoke(granted)
vpnPrepareCallback = null
}
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
scope = CoroutineScope(Dispatchers.Default)
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
channel =
MethodChannel(flutterPluginBinding.binaryMessenger, "${Components.PACKAGE_NAME}/app")
channel.setMethodCallHandler(this)
@@ -418,43 +276,60 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel.setMethodCallHandler(null)
scope.cancel()
vpnPrepareCallback = null
requestNotificationCallback = null
isRequestingNotificationPermission = false
}
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activityRef = WeakReference(binding.activity)
attachToActivity(binding)
}
private fun attachToActivity(binding: ActivityPluginBinding) {
activity = binding.activity
binding.addActivityResultListener(::onActivityResult)
binding.addRequestPermissionsResultListener(::onRequestPermissionsResultListener)
}
override fun onDetachedFromActivityForConfigChanges() {
activityRef = null
activity = null
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
activityRef = WeakReference(binding.activity)
attachToActivity(binding)
}
override fun onDetachedFromActivity() {
channel.invokeMethod("exit", null)
activityRef = null
activity = null
vpnPrepareCallback = null
requestNotificationCallback = null
isRequestingNotificationPermission = false
}
private fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
if (requestCode == VPN_PERMISSION_REQUEST_CODE) {
if (resultCode == FlutterActivity.RESULT_OK) {
invokeVpnPrepareCallback()
}
if (requestCode != VPN_PERMISSION_REQUEST_CODE) {
return false
}
invokeVpnPrepareCallback(resultCode == Activity.RESULT_OK)
return true
}
private fun onRequestPermissionsResultListener(
requestCode: Int, permissions: Array<String>, grantResults: IntArray
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray,
): Boolean {
if (requestCode == NOTIFICATION_PERMISSION_REQUEST_CODE) {
isBlockNotification = true
if (requestCode != NOTIFICATION_PERMISSION_REQUEST_CODE) {
return false
}
skipNotificationPermissionRequest = true
invokeRequestNotificationCallback()
return true
}
private companion object {
const val VPN_PERMISSION_REQUEST_CODE = 1001
const val NOTIFICATION_PERMISSION_REQUEST_CODE = 1002
}
}
@@ -1,10 +1,8 @@
package com.follow.clash.plugins
import com.follow.clash.RunState
import com.follow.clash.Service
import com.follow.clash.State
import com.follow.clash.ServiceController
import com.follow.clash.ServiceState
import com.follow.clash.common.Components
import com.follow.clash.invokeMethodOnMainThread
import com.follow.clash.models.SharedState
import com.google.gson.Gson
import io.flutter.embedding.engine.plugins.FlutterPlugin
@@ -13,127 +11,102 @@ import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
class ServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) {
private lateinit var flutterMethodChannel: MethodChannel
class ServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
private lateinit var channel: MethodChannel
private lateinit var scope: CoroutineScope
private val gson = Gson()
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
flutterMethodChannel = MethodChannel(
flutterPluginBinding.binaryMessenger, "${Components.PACKAGE_NAME}/service"
)
flutterMethodChannel.setMethodCallHandler(this)
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
channel = MethodChannel(binding.binaryMessenger, "${Components.PACKAGE_NAME}/service")
channel.setMethodCallHandler(this)
}
override fun onDetachedFromEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
flutterMethodChannel.setMethodCallHandler(null)
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel.setMethodCallHandler(null)
scope.cancel()
ServiceController.setEventListener(null)
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) = when (call.method) {
"init" -> {
handleInit(result)
}
"shutdown" -> {
handleShutdown(result)
}
"invokeAction" -> {
handleInvokeAction(call, result)
}
"getRunTime" -> {
handleGetRunTime(result)
}
"syncState" -> {
handleSyncState(call, result)
}
"start" -> {
handleStart(result)
}
"stop" -> {
handleStop(result)
}
else -> {
result.notImplemented()
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"init" -> initialize(result)
"shutdown" -> shutdown(result)
"invokeMethod" -> invokeMethod(call, result)
"getRunTime" -> getRunTime(result)
"syncState" -> syncState(call, result)
"start" -> start(result)
"stop" -> stop(result)
else -> result.notImplemented()
}
}
private fun handleInvokeAction(call: MethodCall, result: MethodChannel.Result) {
launch {
val data = call.arguments<String>()!!
Service.invokeAction(data) {
result.success(it)
private fun initialize(result: MethodChannel.Result) {
ServiceController.setEventListener(::sendEvent)
.onSuccess { result.success("") }
.onFailure { error -> result.success(error.message.orEmpty()) }
}
private fun shutdown(result: MethodChannel.Result) {
scope.launch {
ServiceController.unbind()
result.success(true)
}
}
private fun invokeMethod(call: MethodCall, result: MethodChannel.Result) {
val data = call.arguments as? String
if (data == null) {
result.error("INVALID_ARGUMENT", "Method call payload must be a string", null)
return
}
scope.launch {
ServiceController.invokeMethod(data) { response ->
result.success(response)
}.onFailure { error ->
result.error("CORE_ERROR", error.message, null)
}
}
}
private fun handleShutdown(result: MethodChannel.Result) {
Service.unbind()
result.success(true)
}
private fun handleStart(result: MethodChannel.Result) {
State.handleStartService()
result.success(true)
}
private fun handleStop(result: MethodChannel.Result) {
State.handleStopService()
result.success(true)
}
val semaphore = Semaphore(10)
fun handleSendEvent(value: String?) {
launch(Dispatchers.Main) {
semaphore.withPermit {
flutterMethodChannel.invokeMethod("event", value)
}
private fun getRunTime(result: MethodChannel.Result) {
scope.launch {
ServiceState.refresh()
result.success(ServiceState.runTimeMillis)
}
}
private fun onServiceDisconnected(message: String) {
State.runStateFlow.tryEmit(RunState.STOP)
flutterMethodChannel.invokeMethodOnMainThread<Any>("crash", message)
}
private fun handleSyncState(call: MethodCall, result: MethodChannel.Result) {
val data = call.arguments<String>()!!
State.sharedState = Gson().fromJson(data, SharedState::class.java)
launch {
State.syncState()
private fun syncState(call: MethodCall, result: MethodChannel.Result) {
val data = call.arguments as? String
val state = runCatching {
gson.fromJson(data, SharedState::class.java)
}.getOrNull()
if (state == null) {
result.success("Invalid shared state")
return
}
scope.launch {
ServiceState.syncSharedState(state)
result.success("")
}
}
fun handleInit(result: MethodChannel.Result) {
Service.bind()
launch {
Service.setEventListener {
handleSendEvent(it)
}.onSuccess {
result.success("")
}.onFailure {
result.success(it.message)
}
}
Service.onServiceDisconnected = ::onServiceDisconnected
private fun start(result: MethodChannel.Result) {
ServiceState.requestStart()
result.success(true)
}
private fun handleGetRunTime(result: MethodChannel.Result) {
launch {
State.handleSyncState()
result.success(State.runTime)
private fun stop(result: MethodChannel.Result) {
ServiceState.requestStop()
result.success(true)
}
private fun sendEvent(value: String?) {
scope.launch(Dispatchers.Main) {
channel.invokeMethod("event", value)
}
}
}
}
@@ -7,7 +7,6 @@ import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
class TilePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
private lateinit var channel: MethodChannel
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
@@ -21,13 +20,14 @@ class TilePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
}
fun handleStart() {
channel.invokeMethodOnMainThread<Any>("start", null)
channel.invokeMethodOnMainThread("start")
}
fun handleStop() {
channel.invokeMethodOnMainThread<Any>("stop", null)
channel.invokeMethodOnMainThread("stop")
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {}
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
result.notImplemented()
}
}
@@ -1,6 +0,0 @@
<paths>
<files-path
name="files"
path="."/>
</paths>
@@ -1,14 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config xmlns:tools="http://schemas.android.com/tools"
tools:ignore="AcceptsUserCertificates">
<base-config>
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">localhost</domain>
<domain includeSubdomains="true">127.0.0.1</domain>
</domain-config>
</network-security-config>
@@ -1,7 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
-3
View File
@@ -14,9 +14,6 @@ rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
+1 -12
View File
@@ -9,24 +9,13 @@ android {
compileSdk = libs.versions.compileSdk.get().toInt()
defaultConfig {
minSdk = 21
consumerProguardFiles("consumer-rules.pro")
}
buildTypes {
release {
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
minSdk = libs.versions.minSdk.get().toInt()
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
kotlin {
View File
@@ -5,12 +5,12 @@ import android.content.ComponentName
object Components {
const val PACKAGE_NAME = "com.follow.clash"
val MAIN_ACTIVITY =
val mainActivity =
ComponentName(GlobalState.packageName, "${PACKAGE_NAME}.MainActivity")
val TEMP_ACTIVITY =
ComponentName(GlobalState.packageName, "${PACKAGE_NAME}.TempActivity")
val quickActionActivity =
ComponentName(GlobalState.packageName, "${PACKAGE_NAME}.QuickActionActivity")
val BROADCAST_RECEIVER =
ComponentName(GlobalState.packageName, "${PACKAGE_NAME}.BroadcastReceiver")
}
val serviceBroadcastReceiver =
ComponentName(GlobalState.packageName, "${PACKAGE_NAME}.ServiceBroadcastReceiver")
}
@@ -2,7 +2,6 @@ package com.follow.clash.common
import com.google.gson.annotations.SerializedName
enum class QuickAction {
STOP,
START,
@@ -20,4 +19,4 @@ enum class AccessControlMode {
@SerializedName("rejectSelected")
REJECT_SELECTED,
}
}
@@ -13,98 +13,60 @@ import android.content.Context
import android.content.Context.RECEIVER_NOT_EXPORTED
import android.content.Intent
import android.content.IntentFilter
import android.content.ServiceConnection
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.os.RemoteException
import android.util.Log
import androidx.core.content.getSystemService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.retryWhen
import kotlinx.coroutines.withContext
import java.nio.charset.Charset
import kotlin.reflect.KClass
//fun Context.startForegroundServiceCompat(intent: Intent?) {
// if (Build.VERSION.SDK_INT >= 26) {
// startForegroundService(intent)
// } else {
// startService(intent)
// }
//}
val KClass<*>.intent: Intent
get() = Intent(GlobalState.application, this.java)
fun Service.startForegroundCompat(id: Int, notification: Notification) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(id, notification, FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
} else {
startForeground(id, notification)
}
}
val ComponentName.intent: Intent
get() = Intent().apply {
setComponent(this@intent)
setPackage(GlobalState.packageName)
component = this@intent
}
val QuickAction.action: String
get() = "${GlobalState.application.packageName}.action.${this.name}"
val QuickAction.quickIntent: Intent
get() = Components.TEMP_ACTIVITY.intent.apply {
get() = Components.quickActionActivity.intent.apply {
action = this@quickIntent.action
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
val BroadcastAction.action: String
get() = "${GlobalState.application.packageName}.intent.action.${this.name}"
val Context.processName: String?
get() {
val pid = android.os.Process.myPid()
val activityManager = getSystemService<ActivityManager>()
activityManager?.runningAppProcesses?.find { it.pid == pid }?.let {
return it.processName
}
return null
}
val BroadcastAction.quickIntent: Intent
get() = Components.BROADCAST_RECEIVER.intent.apply {
action = this@quickIntent.action
}
get() = getSystemService<ActivityManager>()
?.runningAppProcesses
?.firstOrNull { it.pid == android.os.Process.myPid() }
?.processName
fun BroadcastAction.sendBroadcast() {
val intent = Intent().apply {
action = this@sendBroadcast.action
Log.d("[sendBroadcast]", "$action")
setPackage(GlobalState.packageName)
val broadcastAction = action
val intent = Intent(broadcastAction).apply {
component = Components.serviceBroadcastReceiver
}
GlobalState.log("Send broadcast: $broadcastAction")
GlobalState.application.sendBroadcast(
intent, GlobalState.RECEIVE_BROADCASTS_PERMISSIONS
intent,
GlobalState.receiveBroadcastPermission,
)
}
val Intent.toPendingIntent: PendingIntent
get() = PendingIntent.getActivity(
GlobalState.application,
0,
this,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
fun Service.startForeground(notification: Notification) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val manager = getSystemService(NotificationManager::class.java)
@@ -112,13 +74,21 @@ fun Service.startForeground(notification: Notification) {
if (channel == null) {
channel = NotificationChannel(
GlobalState.NOTIFICATION_CHANNEL,
"SERVICE_CHANNEL",
NotificationManager.IMPORTANCE_LOW
getString(R.string.service_channel_name),
NotificationManager.IMPORTANCE_LOW,
)
manager?.createNotificationChannel(channel)
}
}
startForegroundCompat(GlobalState.NOTIFICATION_ID, notification)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(
GlobalState.NOTIFICATION_ID,
notification,
FOREGROUND_SERVICE_TYPE_SPECIAL_USE,
)
} else {
startForeground(GlobalState.NOTIFICATION_ID, notification)
}
}
@SuppressLint("UnspecifiedRegisterReceiverFlag")
@@ -144,107 +114,3 @@ fun Context.receiveBroadcastFlow(
registerReceiverCompat(receiver, filter)
awaitClose { unregisterReceiver(receiver) }
}
inline fun <reified T : IBinder> Context.bindServiceFlow(
intent: Intent,
flags: Int = Context.BIND_AUTO_CREATE,
maxRetries: Int = 10,
retryDelayMillis: Long = 200L
): Flow<Pair<IBinder?, String>> = callbackFlow {
val connection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
if (binder != null) {
try {
@Suppress("UNCHECKED_CAST") val casted = binder as? T
if (casted != null) {
trySend(Pair(casted, ""))
} else {
trySend(Pair(null, "Binder is not of type ${T::class.java}"))
}
} catch (e: RemoteException) {
trySend(Pair(null, "Failed to link to death: ${e.message}"))
}
} else {
trySend(Pair(null, "Binder empty"))
}
}
override fun onServiceDisconnected(name: ComponentName?) {
trySend(Pair(null, "Service disconnected"))
}
}
val success = withContext(Dispatchers.Main) {
bindService(intent, connection, flags)
}
if (!success) {
throw IllegalStateException("bindService() failed, will retry")
}
awaitClose {
Handler(Looper.getMainLooper()).post {
unbindService(connection)
trySend(Pair(null, ""))
}
}
}.retryWhen { cause, attempt ->
if (attempt < maxRetries && cause is Exception) {
delay(retryDelayMillis)
true
} else {
false
}
}
val Long.formatBytes: String
get() {
val units = arrayOf("B", "KB", "MB", "GB", "TB")
var size = this.toDouble()
var unitIndex = 0
while (size >= 1024 && unitIndex < units.size - 1) {
size /= 1024
unitIndex++
}
return if (unitIndex == 0) {
"${size.toLong()}${units[unitIndex]}"
} else {
"%.1f${units[unitIndex]}".format(size)
}
}
fun String.chunkedForAidl(charset: Charset = Charsets.UTF_8): List<ByteArray> {
val allBytes = toByteArray(charset)
val total = allBytes.size
val maxBytes = when {
total <= 100 * 1024 -> total
total <= 1024 * 1024 -> 64 * 1024
total <= 10 * 1024 * 1024 -> 128 * 1024
else -> 256 * 1024
}
val result = mutableListOf<ByteArray>()
var index = 0
while (index < total) {
val end = minOf(index + maxBytes, total)
result.add(allBytes.copyOfRange(index, end))
index = end
}
return result
}
fun <T : List<ByteArray>> T.formatString(charset: Charset = Charsets.UTF_8): String {
val totalSize = this.sumOf { it.size }
val combined = ByteArray(totalSize)
var offset = 0
forEach { byteArray ->
byteArray.copyInto(combined, offset)
offset += byteArray.size
}
return String(combined, charset)
}
@@ -1,47 +1,47 @@
package com.follow.clash.common
import android.app.Application
import android.util.Log
import com.google.firebase.FirebaseApp
import com.google.firebase.crashlytics.FirebaseCrashlytics
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
object GlobalState : CoroutineScope by CoroutineScope(Dispatchers.Default) {
object GlobalState : CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) {
const val NOTIFICATION_CHANNEL = "FlClash"
const val NOTIFICATION_ID = 1
val packageName: String
get() = application.packageName
val RECEIVE_BROADCASTS_PERMISSIONS: String
get() = "${packageName}.permission.RECEIVE_BROADCASTS"
private var _application: Application? = null
val receiveBroadcastPermission: String
get() = "$packageName.permission.RECEIVE_BROADCASTS"
val application: Application
get() = _application!!
get() = checkNotNull(appInstance) { "GlobalState is not initialized" }
fun log(text: String) {
Log.d("[FlClash]", text)
}
@Volatile
private var appInstance: Application? = null
fun init(application: Application) {
_application = application
appInstance = application
}
fun log(text: String) {
Log.d("FlClash", text)
}
fun setCrashlytics(enable: Boolean) {
_application?.let {
FirebaseApp.initializeApp(it)
FirebaseCrashlytics.getInstance().isCrashlyticsCollectionEnabled = enable
if (enable) {
log("init crashlytics ${it.processName}")
}
FirebaseApp.initializeApp(application)
FirebaseCrashlytics.getInstance().isCrashlyticsCollectionEnabled = enable
if (enable) {
log("Crashlytics enabled for ${application.processName}")
}
}
}
fun didCrashOnPreviousExecution(): Boolean {
FirebaseApp.initializeApp(application)
return FirebaseCrashlytics.getInstance().didCrashOnPreviousExecution()
}
}
@@ -1,78 +0,0 @@
package com.follow.clash.common
import android.content.Intent
import android.os.IBinder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import java.util.concurrent.atomic.AtomicBoolean
class ServiceDelegate<T>(
private val intent: Intent,
private val onServiceDisconnected: ((String) -> Unit)? = null,
private val interfaceCreator: (IBinder) -> T,
) : CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) {
private val _bindingState = AtomicBoolean(false)
private var _serviceState = MutableStateFlow<Pair<T?, String>?>(null)
val serviceState: StateFlow<Pair<T?, String>?> = _serviceState
private var job: Job? = null
private fun handleBind(data: Pair<IBinder?, String>) {
data.first?.let {
_serviceState.value = Pair(interfaceCreator(it), data.second)
} ?: run {
_serviceState.value = Pair(null, data.second)
unbind()
onServiceDisconnected?.invoke(data.second)
_bindingState.set(false)
}
}
fun bind() {
if (_bindingState.compareAndSet(false, true)) {
job?.cancel()
job = null
_serviceState.value = null
job = launch {
runCatching {
GlobalState.application.bindServiceFlow<IBinder>(intent)
.collect { handleBind(it) }
}
}
}
}
suspend inline fun <R> useService(
timeoutMillis: Long = 5000, crossinline block: suspend (T) -> R
): Result<R> {
return runCatching {
withTimeout(timeoutMillis) {
val state = serviceState.filterNotNull().first()
state.first?.let {
withContext(Dispatchers.Default) {
block(it)
}
} ?: throw Exception(state.second)
}
}
}
fun unbind() {
if (_bindingState.compareAndSet(true, false)) {
job?.cancel()
job = null
_serviceState.value = null
}
}
}
@@ -1,14 +0,0 @@
package com.follow.clash.common
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
fun tickerFlow(delayMillis: Long, initialDelayMillis: Long = delayMillis): Flow<Unit> = flow {
delay(initialDelayMillis)
while (true) {
emit(Unit)
delay(delayMillis)
}
}
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="FlClash">FlClash</string>
<string name="app_name">FlClash</string>
<string name="service_channel_name">FlClash Service</string>
</resources>
-16
View File
@@ -13,13 +13,6 @@ android {
minSdk = libs.versions.minSdk.get().toInt()
}
sourceSets {
getByName("main") {
jniLibs.srcDirs("src/main/jniLibs")
}
}
externalNativeBuild {
cmake {
path("src/main/cpp/CMakeLists.txt")
@@ -32,14 +25,6 @@ android {
targetCompatibility = JavaVersion.VERSION_17
}
buildTypes {
release {
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
}
kotlin {
@@ -48,7 +33,6 @@ kotlin {
}
}
dependencies {
implementation(libs.annotation.jvm)
}
+25 -11
View File
@@ -34,9 +34,9 @@ Java_com_follow_clash_core_Core_updateDNS(JNIEnv *env, jobject thiz, jstring dns
extern "C"
JNIEXPORT void JNICALL
Java_com_follow_clash_core_Core_invokeAction(JNIEnv *env, jobject thiz, jstring data, jobject cb) {
Java_com_follow_clash_core_Core_invokeMethod(JNIEnv *env, jobject thiz, jstring data, jobject cb) {
const auto interface = new_global(cb);
invokeAction(interface, get_string(data));
invokeMethod(interface, get_string(data));
}
extern "C"
@@ -54,14 +54,16 @@ extern "C"
JNIEXPORT jstring JNICALL
Java_com_follow_clash_core_Core_getTraffic(JNIEnv *env, jobject thiz,
const jboolean only_statistics_proxy) {
return new_string(getTraffic(only_statistics_proxy));
scoped_string traffic = getTraffic(only_statistics_proxy);
return new_string(traffic);
}
extern "C"
JNIEXPORT jstring JNICALL
Java_com_follow_clash_core_Core_getTotalTraffic(JNIEnv *env, jobject thiz,
const jboolean only_statistics_proxy) {
return new_string(getTotalTraffic(only_statistics_proxy));
scoped_string traffic = getTotalTraffic(only_statistics_proxy);
return new_string(traffic);
}
extern "C"
@@ -106,21 +108,31 @@ call_tun_interface_resolve_process_impl(void *tun_interface, const int protocol,
const char *target,
const int uid) {
ATTACH_JNI();
const auto packageName = reinterpret_cast<jstring>(env->CallObjectMethod(
const auto source_string = new_string(source);
const auto target_string = new_string(target);
const auto package_name = reinterpret_cast<jstring>(env->CallObjectMethod(
static_cast<jobject>(tun_interface),
m_tun_interface_resolve_process,
protocol,
new_string(source),
new_string(target),
source_string,
target_string,
uid));
return get_string(packageName);
env->DeleteLocalRef(source_string);
env->DeleteLocalRef(target_string);
const auto result = get_string(package_name);
if (package_name != nullptr) {
env->DeleteLocalRef(package_name);
}
return result;
}
static void call_invoke_interface_result_impl(void *invoke_interface, const char *data) {
ATTACH_JNI();
const auto value = new_string(data);
env->CallVoidMethod(static_cast<jobject>(invoke_interface),
m_invoke_interface_result,
new_string(data));
value);
env->DeleteLocalRef(value);
}
extern "C"
@@ -166,7 +178,7 @@ Java_com_follow_clash_core_Core_stopTun(JNIEnv *env, jobject thiz) {
extern "C"
JNIEXPORT void JNICALL
Java_com_follow_clash_core_Core_invokeAction(JNIEnv *env, jobject thiz, jstring data, jobject cb) {
Java_com_follow_clash_core_Core_invokeMethod(JNIEnv *env, jobject thiz, jstring data, jobject cb) {
}
extern "C"
@@ -188,11 +200,13 @@ extern "C"
JNIEXPORT jstring JNICALL
Java_com_follow_clash_core_Core_getTraffic(JNIEnv *env, jobject thiz,
const jboolean only_statistics_proxy) {
return env->NewStringUTF("{}");
}
extern "C"
JNIEXPORT jstring JNICALL
Java_com_follow_clash_core_Core_getTotalTraffic(JNIEnv *env, jobject thiz,
const jboolean only_statistics_proxy) {
return env->NewStringUTF("{}");
}
extern "C"
@@ -205,4 +219,4 @@ JNIEXPORT void JNICALL
Java_com_follow_clash_core_Core_quickSetup(JNIEnv *env, jobject thiz, jstring init_params_string,
jstring setup_params_string, jobject cb) {
}
#endif
#endif
+12 -22
View File
@@ -1,7 +1,6 @@
#include "jni_helper.h"
#include <cstdlib>
#include <malloc.h>
#include <cstring>
static JavaVM *global_vm;
@@ -18,15 +17,15 @@ void initialize_jni(JavaVM *vm, JNIEnv *env) {
m_get_bytes = find_method(c_string, "getBytes", "()[B");
}
JavaVM *global_java_vm() {
return global_vm;
}
char *jni_get_string(JNIEnv *env, jstring str) {
if (str == nullptr) {
return static_cast<char *>(calloc(1, 1));
}
const auto array = reinterpret_cast<jbyteArray>(env->CallObjectMethod(str, m_get_bytes));
const int length = env->GetArrayLength(array);
const auto content = static_cast<char *>(malloc(length + 1));
env->GetByteArrayRegion(array, 0, length, reinterpret_cast<jbyte *>(content));
env->DeleteLocalRef(array);
content[length] = 0;
return content;
}
@@ -35,37 +34,28 @@ jstring jni_new_string(JNIEnv *env, const char *str) {
const auto length = static_cast<int>(strlen(str));
const auto array = env->NewByteArray(length);
env->SetByteArrayRegion(array, 0, length, reinterpret_cast<const jbyte *>(str));
return reinterpret_cast<jstring>(env->NewObject(c_string, m_new_string, array));
}
int jni_catch_exception(JNIEnv *env) {
const int result = env->ExceptionCheck();
if (result) {
env->ExceptionDescribe();
env->ExceptionClear();
}
const auto result = reinterpret_cast<jstring>(env->NewObject(c_string, m_new_string, array));
env->DeleteLocalRef(array);
return result;
}
void jni_attach_thread(scoped_jni *jni) {
JavaVM *vm = global_java_vm();
if (vm->GetEnv(reinterpret_cast<void **>(&jni->env), JNI_VERSION_1_6) == JNI_OK) {
if (global_vm->GetEnv(reinterpret_cast<void **>(&jni->env), JNI_VERSION_1_6) == JNI_OK) {
jni->require_release = 0;
return;
}
if (vm->AttachCurrentThread(&jni->env, nullptr) != JNI_OK) {
if (global_vm->AttachCurrentThread(&jni->env, nullptr) != JNI_OK) {
abort();
}
jni->require_release = 1;
}
void jni_detach_thread(const scoped_jni *env) {
JavaVM *vm = global_java_vm();
if (env->require_release) {
vm->DetachCurrentThread();
global_vm->DetachCurrentThread();
}
}
void release_string(char **str) {
free(*str);
}
void release_string(char **value) {
free(*value);
}
+1 -3
View File
@@ -13,13 +13,11 @@ extern jstring jni_new_string(JNIEnv *env, const char *str);
extern char *jni_get_string(JNIEnv *env, jstring str);
extern int jni_catch_exception(JNIEnv *env);
extern void jni_attach_thread(scoped_jni *jni);
extern void jni_detach_thread(const scoped_jni *env);
extern void release_string( char **str);
extern void release_string(char **value);
#define ATTACH_JNI() __attribute__((unused, cleanup(jni_detach_thread))) \
scoped_jni _jni{}; \
@@ -2,9 +2,9 @@ package com.follow.clash.core
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.URL
import java.net.URI
data object Core {
object Core {
private external fun startTun(
fd: Int,
cb: TunInterface,
@@ -13,17 +13,17 @@ data object Core {
dns: String,
)
external fun forceGC(
)
external fun forceGC()
external fun updateDNS(
dns: String,
)
private fun parseInetSocketAddress(address: String): InetSocketAddress {
val url = URL("https://$address")
return InetSocketAddress(InetAddress.getByName(url.host), url.port)
val uri = URI("tcp://$address")
val host = requireNotNull(uri.host) { "Missing host in address: $address" }
require(uri.port >= 0) { "Missing port in address: $address" }
return InetSocketAddress(InetAddress.getByName(host), uri.port)
}
fun startTun(
@@ -45,7 +45,7 @@ data object Core {
protocol: Int,
source: String,
target: String,
uid: Int
uid: Int,
): String {
return resolverProcess(
protocol,
@@ -57,7 +57,7 @@ data object Core {
},
stack,
address,
dns
dns,
)
}
@@ -65,16 +65,16 @@ data object Core {
suspended: Boolean,
)
private external fun invokeAction(
private external fun invokeMethod(
data: String,
cb: InvokeInterface
cb: InvokeInterface,
)
fun invokeAction(
fun invokeMethod(
data: String,
cb: (result: String?) -> Unit
cb: (result: String?) -> Unit,
) {
invokeAction(
invokeMethod(
data,
object : InvokeInterface {
override fun onResult(result: String?) {
@@ -86,33 +86,33 @@ data object Core {
private external fun setEventListener(cb: InvokeInterface?)
fun callSetEventListener(
cb: ((result: String?) -> Unit)?
fun updateEventListener(
callback: ((result: String?) -> Unit)?,
) {
when (cb != null) {
true -> setEventListener(
if (callback == null) {
setEventListener(null)
} else {
setEventListener(
object : InvokeInterface {
override fun onResult(result: String?) {
cb(result)
callback(result)
}
},
)
false -> setEventListener(null)
}
}
fun quickSetup(
initParamsString: String,
setupParamsString: String,
cb: (result: String?) -> Unit,
callback: (result: String?) -> Unit,
) {
quickSetup(
initParamsString,
setupParamsString,
object : InvokeInterface {
override fun onResult(result: String?) {
cb(result)
callback(result)
}
},
)
@@ -121,7 +121,7 @@ data object Core {
private external fun quickSetup(
initParamsString: String,
setupParamsString: String,
cb: InvokeInterface
cb: InvokeInterface,
)
external fun stopTun()
@@ -133,4 +133,4 @@ data object Core {
init {
System.loadLibrary("core")
}
}
}
@@ -5,5 +5,6 @@ import androidx.annotation.Keep
@Keep
interface TunInterface {
fun protect(fd: Int)
fun resolverProcess(protocol: Int, source: String, target: String, uid: Int): String
}
}
-1
View File
@@ -1,6 +1,5 @@
org.gradle.jvmargs=-Xmx4G
android.useAndroidX=true
android.enableJetifier=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
-6
View File
@@ -1,5 +1,4 @@
[versions]
#agp = "8.10.1"
firebaseBom = "34.15.0"
minSdk = "23"
targetSdk = "36"
@@ -9,10 +8,7 @@ coreKtx = "1.17.0"
annotationJvm = "1.9.1"
coreSplashscreen = "1.0.1"
gson = "2.13.1"
kotlin = "2.2.20"
smaliDexlib2 = "3.0.9"
firebaseCrashlyticsKtx = "20.0.6"
firebaseCommonKtx = "22.1.0"
[libraries]
androidx-core = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
@@ -23,5 +19,3 @@ firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "fir
firebase-crashlytics-ndk = { module = "com.google.firebase:firebase-crashlytics-ndk" }
gson = { module = "com.google.code.gson:gson", version.ref = "gson" }
smali-dexlib2 = { module = "com.android.tools.smali:smali-dexlib2", version.ref = "smaliDexlib2" }
firebase-crashlytics-ktx = { group = "com.google.firebase", name = "firebase-crashlytics-ktx", version.ref = "firebaseCrashlyticsKtx" }
firebase-common-ktx = { group = "com.google.firebase", name = "firebase-common-ktx", version.ref = "firebaseCommonKtx" }
+1 -14
View File
@@ -6,29 +6,17 @@ plugins {
android {
namespace = "com.follow.clash.service"
compileSdk = 36
compileSdk = libs.versions.compileSdk.get().toInt()
defaultConfig {
minSdk = libs.versions.minSdk.get().toInt()
}
buildFeatures {
aidl = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildTypes {
release {
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
}
kotlin {
@@ -37,7 +25,6 @@ kotlin {
}
}
dependencies {
implementation(project(":core"))
implementation(project(":common"))
+5 -14
View File
@@ -8,8 +8,7 @@
android:name=".VpnService"
android:exported="false"
android:foregroundServiceType="specialUse"
android:permission="android.permission.BIND_VPN_SERVICE"
android:process=":remote">
android:permission="android.permission.BIND_VPN_SERVICE">
<intent-filter>
<action android:name="android.net.VpnService" />
</intent-filter>
@@ -19,31 +18,23 @@
</service>
<service
android:name=".CommonService"
android:name=".ProxyService"
android:exported="false"
android:foregroundServiceType="specialUse"
android:process=":remote">
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="proxy" />
</service>
<service
android:name=".RemoteService"
android:enabled="true"
android:exported="false"
android:process=":remote" />
<provider
android:name=".FilesProvider"
android:authorities="${applicationId}.files"
android:exported="true"
android:grantUriPermissions="true"
android:permission="android.permission.MANAGE_DOCUMENTS"
android:process=":remote">
android:permission="android.permission.MANAGE_DOCUMENTS">
<intent-filter>
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
</intent-filter>
</provider>
</application>
</manifest>
</manifest>
@@ -1,8 +0,0 @@
// IAckInterface.aidl
package com.follow.clash.service;
import com.follow.clash.service.IAckInterface;
interface IAckInterface {
oneway void onAck();
}
@@ -1,8 +0,0 @@
// ICallbackInterface.aidl
package com.follow.clash.service;
import com.follow.clash.service.IAckInterface;
interface ICallbackInterface {
oneway void onResult(in byte[] data,in boolean isSuccess, in IAckInterface ack);
}
@@ -1,8 +0,0 @@
// IEventInterface.aidl
package com.follow.clash.service;
import com.follow.clash.service.IAckInterface;
interface IEventInterface {
oneway void onEvent(in String id, in byte[] data,in boolean isSuccess, in IAckInterface ack);
}
@@ -1,20 +0,0 @@
// IRemoteInterface.aidl
package com.follow.clash.service;
import com.follow.clash.service.ICallbackInterface;
import com.follow.clash.service.IEventInterface;
import com.follow.clash.service.IResultInterface;
import com.follow.clash.service.IVoidInterface;
import com.follow.clash.service.models.VpnOptions;
import com.follow.clash.service.models.NotificationParams;
interface IRemoteInterface {
void invokeAction(in String data, in ICallbackInterface callback);
void quickSetup(in String initParamsString, in String setupParamsString, in ICallbackInterface callback, in IVoidInterface onStarted);
void updateNotificationParams(in NotificationParams params);
void startService(in VpnOptions options, in long runTime, in IResultInterface result);
void stopService(in IResultInterface result);
void setEventListener(in IEventInterface event);
void setCrashlytics(in boolean enable);
long getRunTime();
}
@@ -1,6 +0,0 @@
// IResultInterface.aidl
package com.follow.clash.service;
interface IResultInterface {
oneway void onResult(in long runTime);
}
@@ -1,6 +0,0 @@
// IVoidInterface.aidl
package com.follow.clash.service;
interface IVoidInterface {
oneway void invoke();
}
@@ -1,4 +0,0 @@
//AccessControl.aidl
package com.follow.clash.service.models;
parcelable AccessControl;
@@ -1,4 +0,0 @@
//NotificationParams.aidl
package com.follow.clash.service.models;
parcelable NotificationParams;
@@ -1,6 +0,0 @@
//VpnOptions.aidl
package com.follow.clash.service.models;
import com.follow.clash.service.models.AccessControl;
parcelable VpnOptions;
@@ -1,64 +0,0 @@
package com.follow.clash.service
import android.app.Service
import android.content.Intent
import android.os.Binder
import android.os.IBinder
import com.follow.clash.core.Core
import com.follow.clash.service.modules.NetworkObserveModule
import com.follow.clash.service.modules.NotificationModule
import com.follow.clash.service.modules.SuspendModule
import com.follow.clash.service.modules.moduleLoader
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
class CommonService : Service(), IBaseService,
CoroutineScope by CoroutineScope(Dispatchers.Default) {
private val self: CommonService
get() = this
private val loader = moduleLoader {
install(NetworkObserveModule(self))
install(NotificationModule(self))
install(SuspendModule(self))
}
override fun onCreate() {
super.onCreate()
handleCreate()
}
override fun onDestroy() {
handleDestroy()
super.onDestroy()
}
override fun onLowMemory() {
Core.forceGC()
super.onLowMemory()
}
private val binder = LocalBinder()
inner class LocalBinder : Binder() {
fun getService(): CommonService = this@CommonService
}
override fun onBind(intent: Intent): IBinder {
return binder
}
override fun start() {
try {
loader.load()
} catch (_: Exception) {
stop()
}
}
override fun stop() {
loader.cancel()
stopSelf()
}
}
@@ -6,60 +6,34 @@ import android.os.CancellationSignal
import android.os.ParcelFileDescriptor
import android.provider.DocumentsContract
import android.provider.DocumentsProvider
import com.follow.clash.common.R as CommonR
import java.io.File
import java.io.FileNotFoundException
import java.io.IOException
class FilesProvider : DocumentsProvider() {
override fun onCreate() = true
companion object {
private const val DEFAULT_ROOT_ID = "0"
private val DEFAULT_DOCUMENT_COLUMNS = arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_MIME_TYPE,
DocumentsContract.Document.COLUMN_FLAGS,
DocumentsContract.Document.COLUMN_SIZE,
)
private val DEFAULT_ROOT_COLUMNS = arrayOf(
DocumentsContract.Root.COLUMN_ROOT_ID,
DocumentsContract.Root.COLUMN_FLAGS,
DocumentsContract.Root.COLUMN_ICON,
DocumentsContract.Root.COLUMN_TITLE,
DocumentsContract.Root.COLUMN_SUMMARY,
DocumentsContract.Root.COLUMN_DOCUMENT_ID
)
}
override fun onCreate(): Boolean {
return true
}
override fun queryRoots(projection: Array<String>?): Cursor {
return MatrixCursor(projection ?: DEFAULT_ROOT_COLUMNS).apply {
newRow().apply {
add(DocumentsContract.Root.COLUMN_ROOT_ID, DEFAULT_ROOT_ID)
add(DocumentsContract.Root.COLUMN_FLAGS, DocumentsContract.Root.FLAG_LOCAL_ONLY)
add(DocumentsContract.Root.COLUMN_ICON, R.drawable.ic_service)
add(DocumentsContract.Root.COLUMN_TITLE, "FlClash")
add(DocumentsContract.Root.COLUMN_SUMMARY, "Data")
add(DocumentsContract.Root.COLUMN_DOCUMENT_ID, "/")
}
override fun queryRoots(projection: Array<String>?): Cursor =
MatrixCursor(projection ?: DEFAULT_ROOT_COLUMNS).apply {
newRow()
.add(DocumentsContract.Root.COLUMN_ROOT_ID, DEFAULT_ROOT_ID)
.add(DocumentsContract.Root.COLUMN_FLAGS, DocumentsContract.Root.FLAG_LOCAL_ONLY)
.add(DocumentsContract.Root.COLUMN_ICON, R.drawable.ic_service)
.add(
DocumentsContract.Root.COLUMN_TITLE,
context?.getString(CommonR.string.app_name).orEmpty(),
)
.add(DocumentsContract.Root.COLUMN_DOCUMENT_ID, ROOT_DOCUMENT_ID)
}
}
override fun queryChildDocuments(
parentDocumentId: String,
projection: Array<String>?,
sortOrder: String?
sortOrder: String?,
): Cursor {
val result = MatrixCursor(resolveDocumentProjection(projection))
val parentFile = if (parentDocumentId == "/") {
context?.filesDir
} else {
File(parentDocumentId)
} ?: throw FileNotFoundException("Parent directory not found")
val result = MatrixCursor(projection ?: DEFAULT_DOCUMENT_COLUMNS)
val parentFile = resolveFile(parentDocumentId)
parentFile.listFiles()?.forEach { file ->
includeFile(result, file)
}
@@ -67,20 +41,18 @@ class FilesProvider : DocumentsProvider() {
}
override fun queryDocument(documentId: String, projection: Array<String>?): Cursor {
val result = MatrixCursor(resolveDocumentProjection(projection))
val file = File(documentId)
includeFile(result, file)
val result = MatrixCursor(projection ?: DEFAULT_DOCUMENT_COLUMNS)
includeFile(result, resolveFile(documentId))
return result
}
override fun openDocument(
documentId: String,
mode: String,
signal: CancellationSignal?
signal: CancellationSignal?,
): ParcelFileDescriptor {
val file = File(documentId)
val accessMode = ParcelFileDescriptor.parseMode(mode)
return ParcelFileDescriptor.open(file, accessMode)
return ParcelFileDescriptor.open(resolveFile(documentId), accessMode)
}
private fun includeFile(result: MatrixCursor, file: File) {
@@ -88,23 +60,54 @@ class FilesProvider : DocumentsProvider() {
add(DocumentsContract.Document.COLUMN_DOCUMENT_ID, file.absolutePath)
add(DocumentsContract.Document.COLUMN_DISPLAY_NAME, file.name)
add(DocumentsContract.Document.COLUMN_SIZE, file.length())
add(
DocumentsContract.Document.COLUMN_FLAGS,
DocumentsContract.Document.FLAG_SUPPORTS_WRITE or DocumentsContract.Document.FLAG_SUPPORTS_DELETE
)
add(DocumentsContract.Document.COLUMN_MIME_TYPE, getDocumentType(file))
val flags = if (file.isFile) {
DocumentsContract.Document.FLAG_SUPPORTS_WRITE
} else {
0
}
val mimeType = if (file.isDirectory) {
DocumentsContract.Document.MIME_TYPE_DIR
} else {
"application/octet-stream"
}
add(DocumentsContract.Document.COLUMN_FLAGS, flags)
add(DocumentsContract.Document.COLUMN_MIME_TYPE, mimeType)
}
}
private fun getDocumentType(file: File): String {
return if (file.isDirectory) {
DocumentsContract.Document.MIME_TYPE_DIR
} else {
"application/octet-stream"
private fun resolveFile(documentId: String): File {
val root = context?.filesDir?.canonicalFile
?: throw FileNotFoundException("App files directory is unavailable")
val file = try {
if (documentId == ROOT_DOCUMENT_ID) root else File(documentId).canonicalFile
} catch (error: IOException) {
throw FileNotFoundException(error.message).apply { initCause(error) }
}
val isInsideRoot = file == root || file.path.startsWith("${root.path}${File.separator}")
if (!isInsideRoot) {
throw FileNotFoundException("Document is outside the app files directory")
}
return file
}
private fun resolveDocumentProjection(projection: Array<String>?): Array<String> {
return projection ?: DEFAULT_DOCUMENT_COLUMNS
private companion object {
const val DEFAULT_ROOT_ID = "0"
const val ROOT_DOCUMENT_ID = "/"
val DEFAULT_DOCUMENT_COLUMNS = arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_MIME_TYPE,
DocumentsContract.Document.COLUMN_FLAGS,
DocumentsContract.Document.COLUMN_SIZE,
)
val DEFAULT_ROOT_COLUMNS = arrayOf(
DocumentsContract.Root.COLUMN_ROOT_ID,
DocumentsContract.Root.COLUMN_FLAGS,
DocumentsContract.Root.COLUMN_ICON,
DocumentsContract.Root.COLUMN_TITLE,
DocumentsContract.Root.COLUMN_SUMMARY,
DocumentsContract.Root.COLUMN_DOCUMENT_ID,
)
}
}
}
@@ -1,21 +0,0 @@
package com.follow.clash.service
import com.follow.clash.common.BroadcastAction
import com.follow.clash.common.GlobalState
import com.follow.clash.common.sendBroadcast
interface IBaseService {
fun handleCreate() {
GlobalState.log("Service create")
BroadcastAction.SERVICE_CREATED.sendBroadcast()
}
fun handleDestroy() {
GlobalState.log("Service destroy")
BroadcastAction.SERVICE_DESTROYED.sendBroadcast()
}
fun start()
fun stop()
}
@@ -0,0 +1,22 @@
package com.follow.clash.service
import android.app.Service
import com.follow.clash.common.BroadcastAction
import com.follow.clash.common.GlobalState
import com.follow.clash.common.sendBroadcast
interface ManagedService {
fun start()
fun stop()
}
internal fun Service.notifyCreated() {
GlobalState.log("Service created")
BroadcastAction.SERVICE_CREATED.sendBroadcast()
}
internal fun Service.notifyDestroyed() {
GlobalState.log("Service destroyed")
BroadcastAction.SERVICE_DESTROYED.sendBroadcast()
}
@@ -0,0 +1,45 @@
package com.follow.clash.service
import android.app.Service
import android.content.Intent
import android.os.Binder
import android.os.IBinder
import com.follow.clash.core.Core
import com.follow.clash.service.modules.ServiceModules
class ProxyService : Service(), ManagedService {
private val modules = ServiceModules(this)
private val binder = LocalBinder()
override fun onDestroy() {
modules.stop()
notifyDestroyed()
super.onDestroy()
}
override fun onLowMemory() {
Core.forceGC()
super.onLowMemory()
}
inner class LocalBinder : Binder() {
val service: ProxyService
get() = this@ProxyService
}
override fun onBind(intent: Intent): IBinder = binder
override fun start() {
try {
modules.start()
} catch (error: Exception) {
stop()
throw error
}
}
override fun stop() {
modules.stop()
stopSelf()
}
}
@@ -1,196 +0,0 @@
package com.follow.clash.service
import android.app.Service
import android.content.Intent
import android.os.IBinder
import com.follow.clash.common.GlobalState
import com.follow.clash.common.ServiceDelegate
import com.follow.clash.common.chunkedForAidl
import com.follow.clash.common.intent
import com.follow.clash.core.Core
import com.follow.clash.service.State.delegate
import com.follow.clash.service.State.intent
import com.follow.clash.service.State.runLock
import com.follow.clash.service.models.NotificationParams
import com.follow.clash.service.models.VpnOptions
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.withLock
import java.util.UUID
import kotlin.coroutines.resume
class RemoteService : Service(),
CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) {
private fun handleStopService(result: IResultInterface) {
launch {
runLock.withLock {
delegate?.useService { service ->
service.stop()
delegate?.unbind()
}
State.runTime = 0
result.onResult(0)
}
}
}
private fun handleServiceDisconnected(message: String) {
GlobalState.log("Background service disconnected: $message")
intent = null
delegate = null
}
private fun handleStartService(runTime: Long, result: IResultInterface) {
launch {
runLock.withLock {
val nextIntent = when (State.options?.enable == true) {
true -> VpnService::class.intent
false -> CommonService::class.intent
}
if (intent != nextIntent) {
delegate?.unbind()
delegate = ServiceDelegate(nextIntent, ::handleServiceDisconnected) { binder ->
when (binder) {
is VpnService.LocalBinder -> binder.getService()
is CommonService.LocalBinder -> binder.getService()
else -> throw IllegalArgumentException("Invalid binder type")
}
}
intent = nextIntent
delegate?.bind()
}
delegate?.useService { service ->
service.start()
}
State.runTime = when (runTime != 0L) {
true -> runTime
false -> System.currentTimeMillis()
}
result.onResult(State.runTime)
}
}
}
private val binder = object : IRemoteInterface.Stub() {
override fun invokeAction(data: String, callback: ICallbackInterface) {
Core.invokeAction(data) {
launch {
runCatching {
val chunks = it?.chunkedForAidl() ?: listOf()
for ((index, chunk) in chunks.withIndex()) {
suspendCancellableCoroutine { cont ->
callback.onResult(
chunk,
index == chunks.lastIndex,
object : IAckInterface.Stub() {
override fun onAck() {
cont.resume(Unit)
}
},
)
}
}
}
}
}
}
override fun quickSetup(
initParamsString: String,
setupParamsString: String,
callback: ICallbackInterface,
onStarted: IVoidInterface
) {
Core.quickSetup(initParamsString, setupParamsString) {
launch {
runCatching {
val chunks = it?.chunkedForAidl() ?: listOf()
for ((index, chunk) in chunks.withIndex()) {
suspendCancellableCoroutine { cont ->
callback.onResult(
chunk,
index == chunks.lastIndex,
object : IAckInterface.Stub() {
override fun onAck() {
cont.resume(Unit)
}
},
)
}
}
}
}
}
onStarted()
}
override fun updateNotificationParams(params: NotificationParams?) {
State.notificationParamsFlow.tryEmit(params)
}
override fun startService(
options: VpnOptions,
runtime: Long,
result: IResultInterface,
) {
GlobalState.log("remote startService")
State.options = options
handleStartService(runtime, result)
}
override fun stopService(result: IResultInterface) {
handleStopService(result)
}
override fun setEventListener(eventListener: IEventInterface?) {
GlobalState.log("RemoveEventListener ${eventListener == null}")
when (eventListener != null) {
true -> Core.callSetEventListener {
launch {
runCatching {
val id = UUID.randomUUID().toString()
val chunks = it?.chunkedForAidl() ?: listOf()
for ((index, chunk) in chunks.withIndex()) {
suspendCancellableCoroutine { cont ->
eventListener.onEvent(
id,
chunk,
index == chunks.lastIndex,
object : IAckInterface.Stub() {
override fun onAck() {
cont.resume(Unit)
}
},
)
}
}
}
}
}
false -> Core.callSetEventListener(null)
}
}
override fun setCrashlytics(enable: Boolean) {
GlobalState.setCrashlytics(enable)
}
override fun getRunTime(): Long {
return State.runTime
}
}
override fun onBind(intent: Intent?): IBinder {
return binder
}
override fun onDestroy() {
GlobalState.log("Remote service destroy")
super.onDestroy()
}
}
@@ -0,0 +1,25 @@
package com.follow.clash.service
import com.follow.clash.service.models.NotificationParams
import com.follow.clash.service.models.VpnOptions
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
object ServiceConfig {
@Volatile
private var currentVpnOptions: VpnOptions? = null
private val mutableNotificationParams = MutableStateFlow(NotificationParams())
val vpnOptions: VpnOptions?
get() = currentVpnOptions
val notificationParams = mutableNotificationParams.asStateFlow()
fun updateVpnOptions(options: VpnOptions) {
currentVpnOptions = options
}
fun updateNotificationParams(params: NotificationParams) {
mutableNotificationParams.value = params
}
}
@@ -1,22 +0,0 @@
package com.follow.clash.service
import android.content.Intent
import com.follow.clash.common.ServiceDelegate
import com.follow.clash.service.models.NotificationParams
import com.follow.clash.service.models.VpnOptions
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.sync.Mutex
object State {
var options: VpnOptions? = null
var notificationParamsFlow: MutableStateFlow<NotificationParams?> = MutableStateFlow(
NotificationParams()
)
val runLock = Mutex()
var runTime: Long = 0L
var delegate: ServiceDelegate<IBaseService>? = null
var intent: Intent? = null
}
@@ -6,52 +6,35 @@ import android.net.ProxyInfo
import android.os.Binder
import android.os.Build
import android.os.IBinder
import android.os.Parcel
import android.os.RemoteException
import android.util.Log
import androidx.core.content.getSystemService
import com.follow.clash.common.AccessControlMode
import com.follow.clash.common.GlobalState
import com.follow.clash.common.R as CommonR
import com.follow.clash.core.Core
import com.follow.clash.service.models.CIDR
import com.follow.clash.service.models.VpnOptions
import com.follow.clash.service.models.getIpv4RouteAddress
import com.follow.clash.service.models.getIpv6RouteAddress
import com.follow.clash.service.models.toCIDR
import com.follow.clash.service.modules.NetworkObserveModule
import com.follow.clash.service.modules.NotificationModule
import com.follow.clash.service.modules.SuspendModule
import com.follow.clash.service.modules.moduleLoader
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import com.follow.clash.service.modules.ServiceModules
import java.net.InetSocketAddress
import java.util.concurrent.ConcurrentHashMap
import android.net.VpnService as SystemVpnService
class VpnService : SystemVpnService(), IBaseService,
CoroutineScope by CoroutineScope(Dispatchers.Default) {
private val self: VpnService
get() = this
private val loader = moduleLoader {
install(NetworkObserveModule(self))
install(NotificationModule(self))
install(SuspendModule(self))
}
override fun onCreate() {
super.onCreate()
handleCreate()
}
class VpnService : SystemVpnService(), ManagedService {
private val modules = ServiceModules(this)
private val binder = LocalBinder()
override fun onDestroy() {
handleDestroy()
modules.stop()
super.onDestroy()
}
private val connectivity by lazy {
getSystemService<ConnectivityManager>()
}
private val uidPageNameMap = mutableMapOf<Int, String>()
private val uidPackageNameMap = ConcurrentHashMap<Int, String>()
private fun resolverProcess(
protocol: Int,
@@ -67,13 +50,15 @@ class VpnService : SystemVpnService(), IBaseService,
if (nextUid == -1) {
return ""
}
if (!uidPageNameMap.containsKey(nextUid)) {
uidPageNameMap[nextUid] = this.packageManager?.getPackagesForUid(nextUid)?.first() ?: ""
return uidPackageNameMap.getOrPut(nextUid) {
packageManager
.getPackagesForUid(nextUid)
?.firstOrNull()
.orEmpty()
}
return uidPageNameMap[nextUid] ?: ""
}
val VpnOptions.address
private val VpnOptions.tunAddress
get(): String = buildString {
append(IPV4_ADDRESS)
if (ipv6) {
@@ -82,7 +67,7 @@ class VpnService : SystemVpnService(), IBaseService,
}
}
val VpnOptions.dns
private val VpnOptions.tunDns
get(): String {
if (dnsHijacking) {
return NET_ANY
@@ -96,156 +81,147 @@ class VpnService : SystemVpnService(), IBaseService,
}
}
override fun onLowMemory() {
Core.forceGC()
super.onLowMemory()
}
private val binder = LocalBinder()
inner class LocalBinder : Binder() {
fun getService(): VpnService = this@VpnService
override fun onTransact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean {
try {
val isSuccess = super.onTransact(code, data, reply, flags)
if (!isSuccess) {
GlobalState.log("VpnService disconnected")
handleDestroy()
}
return isSuccess
} catch (e: RemoteException) {
GlobalState.log("VpnService onTransact $e")
return false
}
}
val service: VpnService
get() = this@VpnService
}
override fun onBind(intent: Intent): IBinder {
return binder
override fun onBind(intent: Intent): IBinder? =
if (intent.action == SystemVpnService.SERVICE_INTERFACE) {
super.onBind(intent)
} else {
binder
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
notifyCreated()
return super.onStartCommand(intent, flags, startId)
}
override fun onRevoke() {
stop()
notifyDestroyed()
}
private fun handleStart(options: VpnOptions) {
val fd = with(Builder()) {
val cidr = IPV4_ADDRESS.toCIDR()
addAddress(cidr.address, cidr.prefixLength)
Log.d(
"addAddress", "address: ${cidr.address} prefixLength:${cidr.prefixLength}"
)
val routeAddress = options.getIpv4RouteAddress()
if (routeAddress.isNotEmpty()) {
try {
routeAddress.forEach { i ->
Log.d(
"addRoute4", "address: ${i.address} prefixLength:${i.prefixLength}"
)
addRoute(i.address, i.prefixLength)
}
} catch (_: Exception) {
addRoute(NET_ANY, 0)
}
} else {
addRoute(NET_ANY, 0)
}
if (options.ipv6) {
try {
val cidr = IPV6_ADDRESS.toCIDR()
Log.d(
"addAddress6", "address: ${cidr.address} prefixLength:${cidr.prefixLength}"
)
addAddress(cidr.address, cidr.prefixLength)
} catch (_: Exception) {
Log.d(
"addAddress6", "IPv6 is not supported."
)
}
try {
val routeAddress = options.getIpv6RouteAddress()
if (routeAddress.isNotEmpty()) {
try {
routeAddress.forEach { i ->
Log.d(
"addRoute6",
"address: ${i.address} prefixLength:${i.prefixLength}"
)
addRoute(i.address, i.prefixLength)
}
} catch (_: Exception) {
addRoute("::", 0)
}
} else {
addRoute(NET_ANY6, 0)
}
} catch (_: Exception) {
addRoute(NET_ANY6, 0)
}
}
addDnsServer(DNS)
if (options.ipv6) {
addDnsServer(DNS6)
}
setMtu(9000)
options.accessControlProps.let { accessControl ->
if (accessControl.enable) {
when (accessControl.mode) {
AccessControlMode.ACCEPT_SELECTED -> {
(accessControl.acceptList + packageName).forEach {
addAllowedApplication(it)
}
}
AccessControlMode.REJECT_SELECTED -> {
(accessControl.rejectList - packageName).forEach {
addDisallowedApplication(it)
}
}
}
}
}
setSession("FlClash")
addAddressAndRoutes(options)
addDnsServers(options)
setMtu(MTU)
configureAccessControl(options)
setSession(getString(CommonR.string.app_name))
setBlocking(false)
if (Build.VERSION.SDK_INT >= 29) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
setMetered(false)
}
if (options.allowBypass) {
allowBypass()
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && options.systemProxy) {
GlobalState.log("Open http proxy")
GlobalState.log("Enable VPN HTTP proxy")
setHttpProxy(
ProxyInfo.buildDirectProxy(
"127.0.0.1", options.port, options.bypassDomain
)
LOCAL_HOST,
options.port,
options.bypassDomain,
),
)
}
establish()?.detachFd()
?: throw NullPointerException("Establish VPN rejected by system")
?: error("VPN establishment was rejected by the system")
}
Core.startTun(
fd,
fd = fd,
protect = this::protect,
resolverProcess = this::resolverProcess,
options.stack,
options.address,
options.dns
stack = options.stack,
address = options.tunAddress,
dns = options.tunDns,
)
}
private fun Builder.addAddressAndRoutes(options: VpnOptions) {
val ipv4Address = IPV4_ADDRESS.toCIDR()
addAddress(ipv4Address.address, ipv4Address.prefixLength)
addRoutes(
routes = options::getIpv4RouteAddress,
fallbackAddress = NET_ANY,
logTag = "addRoute4",
)
if (options.ipv6) {
try {
val ipv6Address = IPV6_ADDRESS.toCIDR()
addAddress(ipv6Address.address, ipv6Address.prefixLength)
} catch (_: Exception) {
GlobalState.log("IPv6 VPN address is not supported")
}
addRoutes(
routes = options::getIpv6RouteAddress,
fallbackAddress = NET_ANY6,
logTag = "addRoute6",
)
}
}
private fun Builder.addRoutes(
routes: () -> List<CIDR>,
fallbackAddress: String,
logTag: String,
) {
val routeList = runCatching(routes).getOrDefault(emptyList())
if (routeList.isEmpty()) {
addRoute(fallbackAddress, 0)
return
}
try {
routeList.forEach { route ->
Log.d(logTag, "address: ${route.address} prefixLength: ${route.prefixLength}")
addRoute(route.address, route.prefixLength)
}
} catch (_: Exception) {
addRoute(fallbackAddress, 0)
}
}
private fun Builder.addDnsServers(options: VpnOptions) {
addDnsServer(DNS)
if (options.ipv6) {
addDnsServer(DNS6)
}
}
private fun Builder.configureAccessControl(options: VpnOptions) {
val accessControl = options.accessControlProps
if (!accessControl.enable) return
when (accessControl.mode) {
AccessControlMode.ACCEPT_SELECTED -> {
(accessControl.acceptList + packageName).forEach(::addAllowedApplication)
}
AccessControlMode.REJECT_SELECTED -> {
(accessControl.rejectList - packageName).forEach(::addDisallowedApplication)
}
}
}
override fun start() {
try {
loader.load()
State.options?.let {
handleStart(it)
}
} catch (_: Exception) {
modules.start()
handleStart(requireNotNull(ServiceConfig.vpnOptions) { "VPN options are missing" })
} catch (error: Exception) {
stop()
throw error
}
}
override fun stop() {
loader.cancel()
modules.stop()
Core.stopTun()
stopSelf()
}
@@ -257,5 +233,7 @@ class VpnService : SystemVpnService(), IBaseService,
private const val DNS6 = "fdfe:dcba:9876::2"
private const val NET_ANY = "0.0.0.0"
private const val NET_ANY6 = "::"
private const val LOCAL_HOST = "127.0.0.1"
private const val MTU = 9000
}
}
}
@@ -1,36 +1,7 @@
package com.follow.clash.service.models
import android.os.Parcel
import android.os.Parcelable
data class NotificationParams(
val title: String = "FlClash",
val stopText: String = "STOP",
val onlyStatisticsProxy: Boolean = false,
) : Parcelable {
constructor(parcel: Parcel) : this(
title = parcel.readString() ?: "FlClash",
stopText = parcel.readString() ?: "STOP",
onlyStatisticsProxy = parcel.readByte() != 0.toByte(),
)
override fun writeToParcel(parcel: Parcel, flags: Int) {
parcel.writeString(title)
parcel.writeString(stopText)
parcel.writeByte(if (onlyStatisticsProxy) 1.toByte() else 0.toByte())
}
override fun describeContents(): Int {
return 0
}
companion object CREATOR : Parcelable.Creator<NotificationParams> {
override fun createFromParcel(parcel: Parcel): NotificationParams {
return NotificationParams(parcel)
}
override fun newArray(size: Int): Array<NotificationParams?> {
return arrayOfNulls(size)
}
}
}
)
@@ -1,25 +1,39 @@
package com.follow.clash.service.models
import com.follow.clash.common.GlobalState
import com.follow.clash.common.formatBytes
import com.follow.clash.core.Core
import com.google.gson.Gson
private val gson = Gson()
data class Traffic(
val up: Long,
val down: Long,
)
private val Long.formatBytes: String
get() {
val units = arrayOf("B", "KB", "MB", "GB", "TB")
var value = toDouble()
var unit = 0
while (value >= 1024 && unit < units.lastIndex) {
value /= 1024
unit++
}
return if (unit == 0) {
"${value.toLong()}${units[unit]}"
} else {
"%.1f${units[unit]}".format(value)
}
}
val Traffic.speedText: String
get() = "${up.formatBytes}/s↑ ${down.formatBytes}/s↓"
fun Core.getSpeedTrafficText(onlyStatisticsProxy: Boolean): String {
try {
val res = getTraffic(onlyStatisticsProxy)
val traffic = Gson().fromJson(res, Traffic::class.java)
return traffic.speedText
} catch (e: Exception) {
GlobalState.log(e.message + "")
return ""
}
}
return runCatching {
gson.fromJson(getTraffic(onlyStatisticsProxy), Traffic::class.java).speedText
}.onFailure { error ->
GlobalState.log("Unable to read traffic: $error")
}.getOrDefault("")
}
@@ -1,8 +1,8 @@
package com.follow.clash.service.models
import android.os.Parcel
import android.os.Parcelable
import com.follow.clash.common.AccessControlMode
import java.net.Inet4Address
import java.net.Inet6Address
import java.net.InetAddress
data class AccessControlProps(
@@ -10,35 +10,7 @@ data class AccessControlProps(
val mode: AccessControlMode,
val acceptList: List<String>,
val rejectList: List<String>,
) : Parcelable {
constructor(parcel: Parcel) : this(
enable = parcel.readByte() != 0.toByte(),
mode = AccessControlMode.valueOf(parcel.readString() ?: AccessControlMode.ACCEPT_SELECTED.name),
acceptList = parcel.createStringArrayList() ?: emptyList(),
rejectList = parcel.createStringArrayList() ?: emptyList(),
)
override fun writeToParcel(parcel: Parcel, flags: Int) {
parcel.writeByte(if (enable) 1.toByte() else 0.toByte())
parcel.writeString(mode.name)
parcel.writeStringList(acceptList)
parcel.writeStringList(rejectList)
}
override fun describeContents(): Int {
return 0
}
companion object CREATOR : Parcelable.Creator<AccessControlProps> {
override fun createFromParcel(parcel: Parcel): AccessControlProps {
return AccessControlProps(parcel)
}
override fun newArray(size: Int): Array<AccessControlProps?> {
return arrayOfNulls(size)
}
}
}
)
data class VpnOptions(
val enable: Boolean,
@@ -51,110 +23,32 @@ data class VpnOptions(
val bypassDomain: List<String>,
val stack: String,
val routeAddress: List<String>,
) : Parcelable {
constructor(parcel: Parcel) : this(
enable = parcel.readByte() != 0.toByte(),
port = parcel.readInt(),
ipv6 = parcel.readByte() != 0.toByte(),
dnsHijacking = parcel.readByte() != 0.toByte(),
accessControlProps = readAccessControlProps(parcel),
allowBypass = parcel.readByte() != 0.toByte(),
systemProxy = parcel.readByte() != 0.toByte(),
bypassDomain = parcel.createStringArrayList() ?: emptyList(),
stack = parcel.readString() ?: "",
routeAddress = parcel.createStringArrayList() ?: emptyList(),
)
)
override fun writeToParcel(parcel: Parcel, flags: Int) {
parcel.writeByte(if (enable) 1.toByte() else 0.toByte())
parcel.writeInt(port)
parcel.writeByte(if (ipv6) 1.toByte() else 0.toByte())
parcel.writeByte(if (dnsHijacking) 1.toByte() else 0.toByte())
parcel.writeParcelable(accessControlProps, flags)
parcel.writeByte(if (allowBypass) 1.toByte() else 0.toByte())
parcel.writeByte(if (systemProxy) 1.toByte() else 0.toByte())
parcel.writeStringList(bypassDomain)
parcel.writeString(stack)
parcel.writeStringList(routeAddress)
}
data class CIDR(
val address: InetAddress,
val prefixLength: Int,
)
override fun describeContents(): Int {
return 0
}
fun VpnOptions.getIpv4RouteAddress(): List<CIDR> = routeAddress
.map(String::toCIDR)
.filter { it.address is Inet4Address }
companion object CREATOR : Parcelable.Creator<VpnOptions> {
@Suppress("DEPRECATION")
private fun readAccessControlProps(parcel: Parcel): AccessControlProps {
return parcel.readParcelable<AccessControlProps>(
AccessControlProps::class.java.classLoader,
) ?: AccessControlProps(
enable = false,
mode = AccessControlMode.ACCEPT_SELECTED,
acceptList = emptyList(),
rejectList = emptyList(),
)
}
override fun createFromParcel(parcel: Parcel): VpnOptions {
return VpnOptions(parcel)
}
override fun newArray(size: Int): Array<VpnOptions?> {
return arrayOfNulls(size)
}
}
}
data class CIDR(val address: InetAddress, val prefixLength: Int)
fun VpnOptions.getIpv4RouteAddress(): List<CIDR> {
return routeAddress.filter {
it.isIpv4()
}.map {
it.toCIDR()
}
}
fun VpnOptions.getIpv6RouteAddress(): List<CIDR> {
return routeAddress.filter {
it.isIpv6()
}.map {
it.toCIDR()
}
}
fun String.isIpv4(): Boolean {
val parts = split("/")
if (parts.size != 2) {
throw IllegalArgumentException("Invalid CIDR format")
}
val address = InetAddress.getByName(parts[0])
return address.address.size == 4
}
fun String.isIpv6(): Boolean {
val parts = split("/")
if (parts.size != 2) {
throw IllegalArgumentException("Invalid CIDR format")
}
val address = InetAddress.getByName(parts[0])
return address.address.size == 16
}
fun VpnOptions.getIpv6RouteAddress(): List<CIDR> = routeAddress
.map(String::toCIDR)
.filter { it.address is Inet6Address }
fun String.toCIDR(): CIDR {
val parts = split("/")
if (parts.size != 2) {
throw IllegalArgumentException("Invalid CIDR format")
}
require(parts.size == 2) { "Invalid CIDR format: $this" }
val ipAddress = parts[0]
val prefixLength =
parts[1].toIntOrNull() ?: throw IllegalArgumentException("Invalid prefix length")
val prefixLength = parts[1].toIntOrNull()
?: throw IllegalArgumentException("Invalid prefix length: ${parts[1]}")
val address = InetAddress.getByName(ipAddress)
val maxPrefix = if (address.address.size == 4) 32 else 128
if (prefixLength < 0 || prefixLength > maxPrefix) {
throw IllegalArgumentException("Invalid prefix length for IP version")
require(prefixLength in 0..maxPrefix) {
"Invalid prefix length $prefixLength for $ipAddress"
}
return CIDR(address, prefixLength)
@@ -1,19 +0,0 @@
package com.follow.clash.service.modules
abstract class Module {
private var isInstall: Boolean = false
protected abstract fun onInstall()
protected abstract fun onUninstall()
fun install() {
isInstall = true
onInstall()
}
fun uninstall() {
onUninstall()
isInstall = false
}
}
@@ -1,51 +0,0 @@
package com.follow.clash.service.modules
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
interface ModuleLoaderScope {
fun <T : Module> install(module: T): T
}
interface ModuleLoader {
fun load()
fun cancel()
}
private val mutex = Mutex()
fun CoroutineScope.moduleLoader(block: suspend ModuleLoaderScope.() -> Unit): ModuleLoader {
val modules = mutableListOf<Module>()
var job: Job? = null
return object : ModuleLoader {
override fun load() {
job = launch(Dispatchers.IO) {
mutex.withLock {
val scope = object : ModuleLoaderScope {
override fun <T : Module> install(module: T): T {
modules.add(module)
module.install()
return module
}
}
scope.block()
}
}
}
override fun cancel() {
launch(Dispatchers.IO) {
job?.cancel()
mutex.withLock {
modules.asReversed().forEach { it.uninstall() }
modules.clear()
}
}
}
}
}
@@ -17,18 +17,20 @@ import java.net.InetAddress
import java.util.concurrent.ConcurrentHashMap
private data class NetworkInfo(
@Volatile var losingMs: Long = 0, @Volatile var dnsList: List<InetAddress> = emptyList()
@Volatile var losingUntilMillis: Long = 0,
@Volatile var dnsList: List<InetAddress> = emptyList(),
) {
fun isAvailable(): Boolean = losingMs < System.currentTimeMillis()
val priorityPenalty: Int
get() = if (losingUntilMillis > System.currentTimeMillis()) 10 else 0
}
class NetworkObserveModule(private val service: Service) : Module() {
internal class NetworkObserveModule(private val service: Service) : ServiceModule {
private val networkInfos = ConcurrentHashMap<Network, NetworkInfo>()
private val connectivity by lazy {
service.getSystemService<ConnectivityManager>()
}
private var preDnsList = listOf<String>()
private var currentDnsList = listOf<String>()
private val request = NetworkRequest.Builder().apply {
addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
@@ -42,108 +44,79 @@ class NetworkObserveModule(private val service: Service) : Module() {
private val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
networkInfos[network] = NetworkInfo()
onUpdateNetwork()
super.onAvailable(network)
updateDns()
}
override fun onLosing(network: Network, maxMsToLive: Int) {
networkInfos[network]?.losingMs = System.currentTimeMillis() + maxMsToLive
onUpdateNetwork()
setUnderlyingNetworks(network)
super.onLosing(network, maxMsToLive)
networkInfos[network]?.losingUntilMillis = System.currentTimeMillis() + maxMsToLive
updateDns()
}
override fun onLost(network: Network) {
networkInfos.remove(network)
onUpdateNetwork()
setUnderlyingNetworks(network)
super.onLost(network)
updateDns()
}
override fun onLinkPropertiesChanged(network: Network, linkProperties: LinkProperties) {
networkInfos[network]?.dnsList = linkProperties.dnsServers
onUpdateNetwork()
setUnderlyingNetworks(network)
super.onLinkPropertiesChanged(network, linkProperties)
updateDns()
}
}
override fun onInstall() {
onUpdateNetwork()
override fun start() {
updateDns()
connectivity?.registerNetworkCallback(request, callback)
}
private fun networkToInt(entry: Map.Entry<Network, NetworkInfo>): Int {
private fun networkPriority(entry: Map.Entry<Network, NetworkInfo>): Int {
val capabilities = connectivity?.getNetworkCapabilities(entry.key)
return when {
capabilities == null -> 100
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> 90
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> 0
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> 1
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && capabilities.hasTransport(
TRANSPORT_USB
) -> 2
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
capabilities.hasTransport(TRANSPORT_USB) -> 2
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_BLUETOOTH) -> 3
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> 4
Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM && capabilities.hasTransport(
TRANSPORT_SATELLITE
) -> 5
Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM &&
capabilities.hasTransport(TRANSPORT_SATELLITE) -> 5
else -> 20
} + (if (entry.value.isAvailable()) 0 else 10)
} + entry.value.priorityPenalty
}
fun onUpdateNetwork() {
val dnsList = (networkInfos.asSequence().minByOrNull { networkToInt(it) }?.value?.dnsList
?: emptyList()).map { x -> x.asSocketAddressText(53) }
if (dnsList == preDnsList) {
@Synchronized
private fun updateDns() {
val dnsList = networkInfos.asSequence()
.minByOrNull(::networkPriority)
?.value
?.dnsList
.orEmpty()
.map { address -> address.asSocketAddressText(DNS_PORT) }
.distinct()
if (dnsList == currentDnsList) {
return
}
preDnsList = dnsList
Core.updateDNS(dnsList.toSet().joinToString(","))
currentDnsList = dnsList
Core.updateDNS(dnsList.joinToString(","))
}
fun setUnderlyingNetworks(network: Network) {
// if (service is VpnService && Build.VERSION.SDK_INT in 22..28) {
// service.setUnderlyingNetworks(arrayOf(network))
// }
}
override fun onUninstall() {
connectivity?.unregisterNetworkCallback(callback)
networkInfos.clear()
onUpdateNetwork()
}
}
fun InetAddress.asSocketAddressText(port: Int): String {
return when (this) {
is Inet6Address -> "[${numericToTextFormat(this)}]:$port"
is Inet4Address -> "${this.hostAddress}:$port"
else -> throw IllegalArgumentException("Unsupported Inet type ${this.javaClass}")
}
}
private fun numericToTextFormat(address: Inet6Address): String {
val src = address.address
val sb = StringBuilder(39)
for (i in 0 until 8) {
sb.append(
Integer.toHexString(
src[i shl 1].toInt() shl 8 and 0xff00 or (src[(i shl 1) + 1].toInt() and 0xff)
)
)
if (i < 7) {
sb.append(":")
override fun stop() {
try {
connectivity?.unregisterNetworkCallback(callback)
} finally {
networkInfos.clear()
updateDns()
}
}
if (address.scopeId > 0) {
sb.append("%")
sb.append(address.scopeId)
}
return sb.toString()
}
}
private const val DNS_PORT = 53
private fun InetAddress.asSocketAddressText(port: Int): String = when (this) {
is Inet6Address -> "[$hostAddress]:$port"
is Inet4Address -> "$hostAddress:$port"
else -> error("Unsupported address type: ${javaClass.name}")
}
@@ -14,39 +14,41 @@ import com.follow.clash.common.QuickAction
import com.follow.clash.common.quickIntent
import com.follow.clash.common.receiveBroadcastFlow
import com.follow.clash.common.startForeground
import com.follow.clash.common.tickerFlow
import com.follow.clash.common.toPendingIntent
import com.follow.clash.core.Core
import com.follow.clash.service.R
import com.follow.clash.service.State
import com.follow.clash.service.ServiceConfig
import com.follow.clash.service.models.NotificationParams
import com.follow.clash.service.models.getSpeedTrafficText
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
data class ExtendedNotificationParams(
private data class ExtendedNotificationParams(
val title: String,
val stopText: String,
val onlyStatisticsProxy: Boolean,
val contentText: String,
)
val NotificationParams.extended: ExtendedNotificationParams
private val NotificationParams.extended: ExtendedNotificationParams
get() = ExtendedNotificationParams(
title, stopText, onlyStatisticsProxy, Core.getSpeedTrafficText(onlyStatisticsProxy)
title,
stopText,
Core.getSpeedTrafficText(onlyStatisticsProxy),
)
class NotificationModule(private val service: Service) : Module() {
private val scope = CoroutineScope(Dispatchers.Default)
override fun onInstall() {
internal class NotificationModule(
private val service: Service,
private val scope: CoroutineScope,
) : ServiceModule {
override fun start() {
update(ServiceConfig.notificationParams.value.extended)
scope.launch {
val screenFlow = service.receiveBroadcastFlow {
addAction(Intent.ACTION_SCREEN_ON)
@@ -58,41 +60,36 @@ class NotificationModule(private val service: Service) : Module() {
}
combine(
tickerFlow(1000, 0), State.notificationParamsFlow, screenFlow
flow {
while (true) {
delay(1_000)
emit(Unit)
}
},
ServiceConfig.notificationParams,
screenFlow,
) { _, params, screenOn ->
params?.extended to screenOn
}.filter { (params, screenOn) -> params != null && screenOn }
.distinctUntilChanged { old, new -> old.first == new.first && old.second == new.second }
.collect { (params, _) ->
update(params!!)
}
State.notificationParamsFlow.value?.let {
update(it.extended)
} ?: run {
update(NotificationParams().extended)
}
params.takeIf { screenOn }?.extended
}.filterNotNull()
.distinctUntilChanged()
.collect(::update)
}
}
private fun isScreenOn(): Boolean {
val pm = service.getSystemService<PowerManager>()
return when (pm != null) {
true -> pm.isInteractive
false -> true
}
}
private fun isScreenOn() =
service.getSystemService<PowerManager>()?.isInteractive ?: true
private val notificationBuilder: NotificationCompat.Builder by lazy {
val intent = Intent().setComponent(Components.MAIN_ACTIVITY)
val intent = Intent().setComponent(Components.mainActivity)
NotificationCompat.Builder(
service, GlobalState.NOTIFICATION_CHANNEL
service,
GlobalState.NOTIFICATION_CHANNEL,
).apply {
setSmallIcon(R.drawable.ic_service)
setContentTitle("FlClash")
setContentIntent(intent.toPendingIntent)
setPriority(NotificationCompat.PRIORITY_HIGH)
setPriority(NotificationCompat.PRIORITY_LOW)
setCategory(NotificationCompat.CATEGORY_SERVICE)
setOngoing(true)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
@@ -100,9 +97,6 @@ class NotificationModule(private val service: Service) : Module() {
}
setShowWhen(true)
setOnlyAlertOnce(true)
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
// setRequestPromotedOngoing(true)
// }
}
}
@@ -113,17 +107,20 @@ class NotificationModule(private val service: Service) : Module() {
setContentText(params.contentText)
clearActions()
addAction(
0, params.stopText, QuickAction.STOP.quickIntent.toPendingIntent
0,
params.stopText,
QuickAction.STOP.quickIntent.toPendingIntent,
).build()
})
},
)
}
override fun onUninstall() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
@Suppress("DEPRECATION")
override fun stop() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
service.stopForeground(STOP_FOREGROUND_REMOVE)
} else {
service.stopForeground(true)
}
scope.cancel()
}
}
}
@@ -0,0 +1,59 @@
package com.follow.clash.service.modules
import android.app.Service
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
internal interface ServiceModule {
fun start()
fun stop()
}
internal class ServiceModules(private val service: Service) {
private var scope: CoroutineScope? = null
private var modules = emptyList<ServiceModule>()
@Synchronized
fun start() {
if (scope != null) return
val nextScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val nextModules = listOf(
NotificationModule(service, nextScope),
NetworkObserveModule(service),
SuspendModule(service, nextScope),
)
val startedModules = mutableListOf<ServiceModule>()
try {
nextModules.forEach { module ->
module.start()
startedModules.add(module)
}
scope = nextScope
modules = nextModules
} catch (error: Throwable) {
nextScope.cancel()
startedModules.asReversed().forEach { module ->
runCatching { module.stop() }
}
throw error
}
}
@Synchronized
fun stop() {
val currentScope = scope ?: return
val currentModules = modules
scope = null
modules = emptyList()
currentScope.cancel()
currentModules.asReversed().forEach { module ->
runCatching { module.stop() }
}
}
}
@@ -7,55 +7,41 @@ import androidx.core.content.getSystemService
import com.follow.clash.common.receiveBroadcastFlow
import com.follow.clash.core.Core
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
internal class SuspendModule(
private val service: Service,
private val scope: CoroutineScope,
) : ServiceModule {
private fun isScreenOn() =
service.getSystemService<PowerManager>()?.isInteractive ?: true
class SuspendModule(private val service: Service) : Module() {
private val scope = CoroutineScope(Dispatchers.Default)
private val isDeviceIdle: Boolean
get() = service.getSystemService<PowerManager>()?.isDeviceIdleMode ?: true
private fun isScreenOn(): Boolean {
val pm = service.getSystemService<PowerManager>()
return when (pm != null) {
true -> pm.isInteractive
false -> true
}
private fun updateSuspension(screenOn: Boolean) {
Core.suspended(!screenOn && isDeviceIdle)
}
val isDeviceIdleMode: Boolean
get() {
return service.getSystemService<PowerManager>()?.isDeviceIdleMode ?: true
}
private fun onUpdate(isScreenOn: Boolean) {
if (isScreenOn) {
Core.suspended(false)
return
}
Core.suspended(isDeviceIdleMode)
}
override fun onInstall() {
override fun start() {
scope.launch {
val screenFlow = service.receiveBroadcastFlow {
addAction(Intent.ACTION_SCREEN_ON)
addAction(Intent.ACTION_SCREEN_OFF)
}.map { intent ->
intent.action == Intent.ACTION_SCREEN_ON
addAction(PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED)
}.map {
isScreenOn()
}.onStart {
emit(isScreenOn())
}
screenFlow.collect {
onUpdate(it)
}
screenFlow.collect(::updateSuspension)
}
}
override fun onUninstall() {
scope.cancel()
override fun stop() {
Core.suspended(false)
}
}
}
+2 -3
View File
@@ -21,11 +21,10 @@ 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.google.gms.google-services") version ("4.5.0") apply false
id("com.google.firebase.crashlytics") version ("3.0.7") 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
}
include(":app")
include(":core")
include(":service")
+3 -1
View File
@@ -318,6 +318,8 @@
"messageTest": "Message test",
"messageTestTip": "This is a message.",
"crashTest": "Crash test",
"crashDetected": "Crash detected",
"crashDetectedTip": "The app crashed during the previous run. To prevent repeated crashes, the current profile has been cleared and automatic configuration setup was skipped.",
"clearData": "Clear Data",
"textScale": "Text Scaling",
"internet": "Internet",
@@ -547,7 +549,7 @@
"hoursCount": "{count} hours",
"geoResources": "Geo Resources",
"geoUpdating": "Updating {name}...",
"geoSkipped": "{name} skipped",
"geoSkipped": "{name} is already up to date",
"geoUpdated": "{name} updated",
"secondsCount": "{count} seconds",
"entriesCount": "{count} entries"
+3 -1
View File
@@ -318,6 +318,8 @@
"messageTest": "メッセージテスト",
"messageTestTip": "これはメッセージです。",
"crashTest": "クラッシュテスト",
"crashDetected": "クラッシュを検出しました",
"crashDetectedTip": "前回の実行中にアプリがクラッシュしました。クラッシュの繰り返しを防ぐため、現在のプロファイルを解除し、設定の自動セットアップをスキップしました。",
"clearData": "データを消去",
"textScale": "テキストスケーリング",
"internet": "インターネット",
@@ -547,7 +549,7 @@
"hoursCount": "{count} 時間",
"geoResources": "Geoリソース",
"geoUpdating": "{name}を更新中...",
"geoSkipped": "{name} スキップ済み",
"geoSkipped": "{name} はすでに最新です",
"geoUpdated": "{name} 更新済み",
"secondsCount": "{count} 秒",
"entriesCount": "{count} エントリ"
+3 -1
View File
@@ -318,6 +318,8 @@
"messageTest": "Тестирование сообщения",
"messageTestTip": "Это сообщение.",
"crashTest": "Тест на сбои",
"crashDetected": "Обнаружен сбой",
"crashDetectedTip": "Во время предыдущего запуска произошёл сбой приложения. Чтобы предотвратить повторный сбой, текущий профиль был сброшен, а автоматическая настройка конфигурации пропущена.",
"clearData": "Очистить данные",
"textScale": "Масштабирование текста",
"internet": "Интернет",
@@ -547,7 +549,7 @@
"hoursCount": "{count} часов",
"geoResources": "Ресурсы Geo",
"geoUpdating": "Обновление {name}...",
"geoSkipped": "{name} пропущено",
"geoSkipped": "Для {name} уже установлена последняя версия",
"geoUpdated": "{name} обновлено",
"secondsCount": "{count} секунд",
"entriesCount": "{count} записей"
+3 -1
View File
@@ -318,6 +318,8 @@
"messageTest": "消息测试",
"messageTestTip": "这是一条消息。",
"crashTest": "崩溃测试",
"crashDetected": "检测到崩溃",
"crashDetectedTip": "检测到应用上次运行发生崩溃。为避免重复崩溃,已清除当前配置选择,并跳过本次自动配置。",
"clearData": "清除数据",
"textScale": "文本缩放",
"internet": "互联网",
@@ -547,7 +549,7 @@
"hoursCount": "{count} 小时",
"geoResources": "Geo 资源",
"geoUpdating": "正在更新 {name}...",
"geoSkipped": "{name} 已跳过",
"geoSkipped": "{name} 已是最新版本",
"geoUpdated": "{name} 已更新",
"secondsCount": "{count} 秒",
"entriesCount": "{count} 个条目"
-193
View File
@@ -1,193 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"runtime"
"unsafe"
)
type Action struct {
Id string `json:"id"`
Method Method `json:"method"`
Data interface{} `json:"data"`
}
type ActionResult struct {
Id string `json:"id"`
Method Method `json:"method"`
Data interface{} `json:"data"`
Code int `json:"code"`
callback unsafe.Pointer
}
func (result ActionResult) Json() ([]byte, error) {
data, err := json.Marshal(result)
return data, err
}
func (result ActionResult) success(data interface{}) {
result.Code = 0
result.Data = data
result.send()
}
func (result ActionResult) error(data interface{}) {
result.Code = -1
result.Data = data
result.send()
}
func handleAction(action *Action, result ActionResult) {
defer func() {
if r := recover(); r != nil {
buf := make([]byte, 4096)
n := runtime.Stack(buf, false)
logError("panic in handleAction(%s): %v\n%s", action.Method, r, buf[:n])
result.error(fmt.Sprintf("internal panic: %v", r))
}
}()
switch action.Method {
case initClashMethod:
paramsString := action.Data.(string)
result.success(handleInitClash(paramsString))
return
case getIsInitMethod:
result.success(handleGetIsInit())
return
case forceGcMethod:
handleForceGC()
result.success(true)
return
case shutdownMethod:
result.success(handleShutdown())
return
case validateConfigMethod:
path := action.Data.(string)
result.success(handleValidateConfig(path))
return
case updateConfigMethod:
data := []byte(action.Data.(string))
result.success(handleUpdateConfig(data))
return
case setupConfigMethod:
data := []byte(action.Data.(string))
result.success(handleSetupConfig(data))
return
case getProxiesMethod:
result.success(handleGetProxies())
return
case changeProxyMethod:
data := action.Data.(string)
handleChangeProxy(data, func(value string) {
result.success(value)
})
return
case getTrafficMethod:
data := action.Data.(bool)
result.success(handleGetTraffic(data))
return
case getTotalTrafficMethod:
data := action.Data.(bool)
result.success(handleGetTotalTraffic(data))
return
case resetTrafficMethod:
handleResetTraffic()
result.success(true)
return
case asyncTestDelayMethod:
data := action.Data.(string)
handleAsyncTestDelay(data, func(value string) {
result.success(value)
})
return
case getConnectionsMethod:
result.success(handleGetConnections())
return
case closeConnectionsMethod:
result.success(handleCloseConnections())
return
case resetConnectionsMethod:
result.success(handleResetConnections())
return
case getConfigMethod:
path := action.Data.(string)
config, err := handleGetConfig(path)
if err != nil {
result.error(err)
return
}
result.success(config)
return
case closeConnectionMethod:
id := action.Data.(string)
result.success(handleCloseConnection(id))
return
case getExternalProvidersMethod:
result.success(handleGetExternalProviders())
return
case getExternalProviderMethod:
externalProviderName := action.Data.(string)
result.success(handleGetExternalProvider(externalProviderName))
return
case updateGeoDataMethod:
geoType := action.Data.(string)
handleUpdateGeoData(geoType)
result.success("")
return
case updateExternalProviderMethod:
providerName := action.Data.(string)
handleUpdateExternalProvider(providerName, func(value string) {
result.success(value)
})
return
case sideLoadExternalProviderMethod:
paramsString := action.Data.(string)
var params = map[string]string{}
err := json.Unmarshal([]byte(paramsString), &params)
if err != nil {
result.success(err.Error())
return
}
providerName := params["providerName"]
data := params["data"]
handleSideLoadExternalProvider(providerName, []byte(data), func(value string) {
result.success(value)
})
return
case startLogMethod:
handleStartLog()
result.success(true)
return
case stopLogMethod:
handleStopLog()
result.success(true)
return
case startListenerMethod:
result.success(handleStartListener())
return
case stopListenerMethod:
result.success(handleStopListener())
return
case getCountryCodeMethod:
ip := action.Data.(string)
handleGetCountryCode(ip, func(value string) {
result.success(value)
})
return
case getMemoryMethod:
handleGetMemory(func(value string) {
result.success(value)
})
return
case crashMethod:
result.success(true)
handleCrash()
case deleteFile:
path := action.Data.(string)
handleDelFile(path, result)
return
default:
nextHandle(action, result)
}
}
+15 -5
View File
@@ -229,11 +229,21 @@ func updateConfig(params *UpdateParams) {
if params.Tun != nil {
general.Tun.Enable = params.Tun.Enable
general.Tun.AutoRoute = *params.Tun.AutoRoute
general.Tun.Device = *params.Tun.Device
general.Tun.RouteAddress = *params.Tun.RouteAddress
general.Tun.DNSHijack = *params.Tun.DNSHijack
general.Tun.Stack = *params.Tun.Stack
if params.Tun.AutoRoute != nil {
general.Tun.AutoRoute = *params.Tun.AutoRoute
}
if params.Tun.Device != nil {
general.Tun.Device = *params.Tun.Device
}
if params.Tun.RouteAddress != nil {
general.Tun.RouteAddress = *params.Tun.RouteAddress
}
if params.Tun.DNSHijack != nil {
general.Tun.DNSHijack = *params.Tun.DNSHijack
}
if params.Tun.Stack != nil {
general.Tun.Stack = *params.Tun.Stack
}
}
if params.GeoAutoUpdate != nil {
+41 -36
View File
@@ -48,8 +48,8 @@ type tunSchema struct {
}
type ChangeProxyParams struct {
GroupName *string `json:"group-name"`
ProxyName *string `json:"proxy-name"`
GroupName string `json:"group-name"`
ProxyName string `json:"proxy-name"`
}
type TestDelayParams struct {
@@ -58,6 +58,11 @@ type TestDelayParams struct {
Timeout int64 `json:"timeout"`
}
type Traffic struct {
Up int64 `json:"up"`
Down int64 `json:"down"`
}
type ExternalProvider struct {
Name string `json:"name"`
Type string `json:"type"`
@@ -74,42 +79,42 @@ type ProxiesData struct {
}
const (
messageMethod Method = "message"
initClashMethod Method = "initClash"
getIsInitMethod Method = "getIsInit"
forceGcMethod Method = "forceGc"
shutdownMethod Method = "shutdown"
validateConfigMethod Method = "validateConfig"
updateConfigMethod Method = "updateConfig"
getProxiesMethod Method = "getProxies"
changeProxyMethod Method = "changeProxy"
getTrafficMethod Method = "getTraffic"
getTotalTrafficMethod Method = "getTotalTraffic"
resetTrafficMethod Method = "resetTraffic"
asyncTestDelayMethod Method = "asyncTestDelay"
getConnectionsMethod Method = "getConnections"
closeConnectionsMethod Method = "closeConnections"
resetConnectionsMethod Method = "resetConnections"
closeConnectionMethod Method = "closeConnection"
getExternalProvidersMethod Method = "getExternalProviders"
getExternalProviderMethod Method = "getExternalProvider"
getCountryCodeMethod Method = "getCountryCode"
getMemoryMethod Method = "getMemory"
updateGeoDataMethod Method = "updateGeoData"
updateExternalProviderMethod Method = "updateExternalProvider"
sideLoadExternalProviderMethod Method = "sideLoadExternalProvider"
startLogMethod Method = "startLog"
stopLogMethod Method = "stopLog"
startListenerMethod Method = "startListener"
stopListenerMethod Method = "stopListener"
updateDnsMethod Method = "updateDns"
crashMethod Method = "crash"
setupConfigMethod Method = "setupConfig"
getConfigMethod Method = "getConfig"
deleteFile Method = "deleteFile"
messageMethod CoreMethod = "message"
initClashMethod CoreMethod = "initClash"
getIsInitMethod CoreMethod = "getIsInit"
forceGcMethod CoreMethod = "forceGc"
shutdownMethod CoreMethod = "shutdown"
validateConfigMethod CoreMethod = "validateConfig"
updateConfigMethod CoreMethod = "updateConfig"
getProxiesMethod CoreMethod = "getProxies"
changeProxyMethod CoreMethod = "changeProxy"
getTrafficMethod CoreMethod = "getTraffic"
getTotalTrafficMethod CoreMethod = "getTotalTraffic"
resetTrafficMethod CoreMethod = "resetTraffic"
asyncTestDelayMethod CoreMethod = "asyncTestDelay"
getConnectionsMethod CoreMethod = "getConnections"
closeConnectionsMethod CoreMethod = "closeConnections"
resetConnectionsMethod CoreMethod = "resetConnections"
closeConnectionMethod CoreMethod = "closeConnection"
getExternalProvidersMethod CoreMethod = "getExternalProviders"
getExternalProviderMethod CoreMethod = "getExternalProvider"
getCountryCodeMethod CoreMethod = "getCountryCode"
getMemoryMethod CoreMethod = "getMemory"
updateGeoDataMethod CoreMethod = "updateGeoData"
updateExternalProviderMethod CoreMethod = "updateExternalProvider"
sideLoadExternalProviderMethod CoreMethod = "sideLoadExternalProvider"
startLogMethod CoreMethod = "startLog"
stopLogMethod CoreMethod = "stopLog"
startListenerMethod CoreMethod = "startListener"
stopListenerMethod CoreMethod = "stopListener"
updateDnsMethod CoreMethod = "updateDns"
crashMethod CoreMethod = "crash"
setupConfigMethod CoreMethod = "setupConfig"
getConfigMethod CoreMethod = "getConfig"
clearEffectMethod CoreMethod = "clearEffect"
)
type Method string
type CoreMethod string
type MessageType string
+16 -15
View File
@@ -14,7 +14,7 @@ require (
require (
github.com/RyuaNerin/go-krypto v1.3.0 // indirect
github.com/Yawning/aez v0.0.0-20211027044916-e49e68abd344 // indirect
github.com/ajg/form v1.5.1 // indirect
github.com/ajg/form v1.7.1 // indirect
github.com/akutz/memconn v0.1.0 // indirect
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect
github.com/andybalholm/brotli v1.1.1 // indirect
@@ -25,7 +25,7 @@ require (
github.com/coreos/go-iptables v0.8.0 // indirect
github.com/dlclark/regexp2 v1.12.0 // indirect
github.com/dunglas/httpsfv v1.0.2 // indirect
github.com/enfein/mieru/v3 v3.33.0 // indirect
github.com/enfein/mieru/v3 v3.34.0 // indirect
github.com/ericlagergren/aegis v0.0.0-20250325060835-cd0defd64358 // indirect
github.com/ericlagergren/polyval v0.0.0-20220411101811-e25bc10ba391 // indirect
github.com/ericlagergren/siv v0.0.0-20220507050439-0b757b3aa5f1 // indirect
@@ -55,13 +55,13 @@ require (
github.com/mdlayher/netlink v1.7.2 // indirect
github.com/mdlayher/socket v0.5.1 // indirect
github.com/metacubex/age v0.0.0-20260603010618-28d156b4ea78 // indirect
github.com/metacubex/amneziawg-go v0.0.0-20251104174305-5a0e9f7e361d // indirect
github.com/metacubex/amneziawg-go v0.0.0-20260612143004-19b4f1cdd5ec // indirect
github.com/metacubex/ascon v0.1.0 // indirect
github.com/metacubex/bart v0.26.0 // indirect
github.com/metacubex/bbolt v0.0.0-20250725135710-010dbbbb7a5b // indirect
github.com/metacubex/bbolt v0.0.0-20260706163408-d4ec34ad7c48 // indirect
github.com/metacubex/blake3 v0.1.0 // indirect
github.com/metacubex/chacha v0.1.5 // indirect
github.com/metacubex/chi v0.1.0 // indirect
github.com/metacubex/chi v0.1.1 // indirect
github.com/metacubex/connect-ip-go v0.0.0-20260412152424-e1625567920a // indirect
github.com/metacubex/cpu v0.1.1 // indirect
github.com/metacubex/edwards25519 v1.2.0 // indirect
@@ -71,32 +71,33 @@ require (
github.com/metacubex/hkdf v0.1.0 // indirect
github.com/metacubex/hpke v0.1.0 // indirect
github.com/metacubex/http v0.1.6 // indirect
github.com/metacubex/jls-quic-go v0.0.0-20260717074316-85a8decdd355 // indirect
github.com/metacubex/jls-tls v0.0.0-20260716145614-4bf88db633e2 // indirect
github.com/metacubex/jsonv2 v0.0.0-20260518173308-f4597c22f1df // indirect
github.com/metacubex/kcp-go v0.0.0-20260105040817-550693377604 // indirect
github.com/metacubex/mhurl v0.1.0 // indirect
github.com/metacubex/mlkem v0.1.0 // indirect
github.com/metacubex/nftables v0.0.0-20260426003805-208c2c1ba2cb // indirect
github.com/metacubex/qpack v0.6.0 // indirect
github.com/metacubex/quic-go v0.59.1-0.20260520020949-fcd18c7b6ace // indirect
github.com/metacubex/quic-go v0.59.1-0.20260606115121-0662b57ad5bf // indirect
github.com/metacubex/randv2 v0.2.0 // indirect
github.com/metacubex/restls-client-go v0.1.7 // indirect
github.com/metacubex/restls-client-go v0.1.8 // indirect
github.com/metacubex/sevenzip v1.6.4 // indirect
github.com/metacubex/sing v0.5.7 // indirect
github.com/metacubex/sing-mux v0.3.9 // indirect
github.com/metacubex/sing-mux v0.3.10 // indirect
github.com/metacubex/sing-quic v0.0.0-20260527143057-68e10a6afdc3 // indirect
github.com/metacubex/sing-shadowsocks v0.2.12 // indirect
github.com/metacubex/sing-shadowsocks2 v0.2.7 // indirect
github.com/metacubex/sing-shadowtls v0.0.0-20260517015314-c11c36474edc // indirect
github.com/metacubex/sing-tun v0.4.20 // indirect
github.com/metacubex/sing-tun v0.4.21 // indirect
github.com/metacubex/sing-vmess v0.2.5 // indirect
github.com/metacubex/sing-wireguard v0.0.0-20260520151737-7e7c7c1b854c // indirect
github.com/metacubex/smux v0.0.0-20260105030934-d0c8756d3141 // indirect
github.com/metacubex/ssh v0.1.0 // indirect
github.com/metacubex/tailscale v0.0.0-20260520011538-f23132fac4b7 // indirect
github.com/metacubex/tailscale-wireguard-go v0.0.0-20260521124654-e1bf77ef79af // indirect
github.com/metacubex/tfo-go v0.0.0-20251130171125-413e892ac443 // indirect
github.com/metacubex/tls v0.1.6 // indirect
github.com/metacubex/utls v1.8.4 // indirect
github.com/metacubex/tailscale v0.0.0-20260711142031-e2257fe61058 // indirect
github.com/metacubex/tailscale-wireguard-go v0.0.0-20260623093519-06ea214022e4 // indirect
github.com/metacubex/tfo-go v0.0.0-20260623020846-376a77860b8c // indirect
github.com/metacubex/tls v0.1.7 // indirect
github.com/metacubex/utls v1.8.7 // indirect
github.com/metacubex/wireguard-go v0.0.0-20250820062549-a6cecdd7f57f // indirect
github.com/metacubex/yamux v0.0.0-20250918083631-dd5f17c0be49 // indirect
github.com/miekg/dns v1.1.63 // indirect
+32 -30
View File
@@ -6,8 +6,8 @@ github.com/RyuaNerin/testingutil v0.1.0 h1:IYT6JL57RV3U2ml3dLHZsVtPOP6yNK7WUVdzz
github.com/RyuaNerin/testingutil v0.1.0/go.mod h1:yTqj6Ta/ycHMPJHRyO12Mz3VrvTloWOsy23WOZH19AA=
github.com/Yawning/aez v0.0.0-20211027044916-e49e68abd344 h1:cDVUiFo+npB0ZASqnw4q90ylaVAbnYyx0JYqK4YcGok=
github.com/Yawning/aez v0.0.0-20211027044916-e49e68abd344/go.mod h1:9pIqrY6SXNL8vjRQE5Hd/OL5GyK/9MrGUWs87z/eFfk=
github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU=
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
github.com/ajg/form v1.7.1 h1:OsnBDzTkrWdrxvEnO68I72ZVGJGNaMwPhoAm0V+llgc=
github.com/ajg/form v1.7.1/go.mod h1:HL757PzLyNkj5AIfptT6L+iGNeXTlnrr/oDePGc/y7Q=
github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A=
github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
@@ -34,8 +34,8 @@ github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dunglas/httpsfv v1.0.2 h1:iERDp/YAfnojSDJ7PW3dj1AReJz4MrwbECSSE59JWL0=
github.com/dunglas/httpsfv v1.0.2/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg=
github.com/enfein/mieru/v3 v3.33.0 h1:hv2jK8nqYHwpSG86U2rpZR2I8Aff1/J3ifRmd9NBbFc=
github.com/enfein/mieru/v3 v3.33.0/go.mod h1:zJBUCsi5rxyvHM8fjFf+GLaEl4OEjjBXr1s5F6Qd3hM=
github.com/enfein/mieru/v3 v3.34.0 h1:8yeaPORvfSQtdfEH+Arw9wttDyFxkik8My4zFdGu79Y=
github.com/enfein/mieru/v3 v3.34.0/go.mod h1:zJBUCsi5rxyvHM8fjFf+GLaEl4OEjjBXr1s5F6Qd3hM=
github.com/ericlagergren/aegis v0.0.0-20250325060835-cd0defd64358 h1:kXYqH/sL8dS/FdoFjr12ePjnLPorPo2FsnrHNuXSDyo=
github.com/ericlagergren/aegis v0.0.0-20250325060835-cd0defd64358/go.mod h1:hkIFzoiIPZYxdFOOLyDho59b7SrDfo+w3h+yWdlg45I=
github.com/ericlagergren/polyval v0.0.0-20220411101811-e25bc10ba391 h1:8j2RH289RJplhA6WfdaPqzg1MjH2K8wX5e0uhAxrw2g=
@@ -106,20 +106,20 @@ github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos
github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ=
github.com/metacubex/age v0.0.0-20260603010618-28d156b4ea78 h1:LqWr0vb9zDNuQS+jJd4fnRYk/SEI7KJ7TDe/L4WFK48=
github.com/metacubex/age v0.0.0-20260603010618-28d156b4ea78/go.mod h1:BTBG/iVY7rg3qq5WdVCg0GFk58CSvCDSbjy8I7kEx/c=
github.com/metacubex/amneziawg-go v0.0.0-20251104174305-5a0e9f7e361d h1:vAJ0ZT4aO803F1uw2roIA9yH7Sxzox34tVVyye1bz6c=
github.com/metacubex/amneziawg-go v0.0.0-20251104174305-5a0e9f7e361d/go.mod h1:MsM/5czONyXMJ3PRr5DbQ4O/BxzAnJWOIcJdLzW6qHY=
github.com/metacubex/amneziawg-go v0.0.0-20260612143004-19b4f1cdd5ec h1:nRHevF7PmvDKjkYPjQCU7NUfVrr3Sry4QOPxpqoyo8U=
github.com/metacubex/amneziawg-go v0.0.0-20260612143004-19b4f1cdd5ec/go.mod h1:MsM/5czONyXMJ3PRr5DbQ4O/BxzAnJWOIcJdLzW6qHY=
github.com/metacubex/ascon v0.1.0 h1:6ZWxmXYszT1XXtwkf6nxfFhc/OTtQ9R3Vyj1jN32lGM=
github.com/metacubex/ascon v0.1.0/go.mod h1:eV5oim4cVPPdEL8/EYaTZ0iIKARH9pnhAK/fcT5Kacc=
github.com/metacubex/bart v0.26.0 h1:d/bBTvVatfVWGfQbiDpYKI1bXUJgjaabB2KpK1Tnk6w=
github.com/metacubex/bart v0.26.0/go.mod h1:DCcyfP4MC+Zy7sLK7XeGuMw+P5K9mIRsYOBgiE8icsI=
github.com/metacubex/bbolt v0.0.0-20250725135710-010dbbbb7a5b h1:j7dadXD8I2KTmMt8jg1JcaP1ANL3JEObJPdANKcSYPY=
github.com/metacubex/bbolt v0.0.0-20250725135710-010dbbbb7a5b/go.mod h1:+WmP0VJZDkDszvpa83HzfUp6QzARl/IKkMorH4+nODw=
github.com/metacubex/bbolt v0.0.0-20260706163408-d4ec34ad7c48 h1:D+AoQ7g/ZeZAHAapelrZB0xUNRcDGuoNLcGmHCnC9Ss=
github.com/metacubex/bbolt v0.0.0-20260706163408-d4ec34ad7c48/go.mod h1:yOfzykxfYIEVOtEys6KOKwxy75M2I/KtOQ+p3Kzc8/k=
github.com/metacubex/blake3 v0.1.0 h1:KGnjh/56REO7U+cgZA8dnBhxdP7jByrG7hTP+bu6cqY=
github.com/metacubex/blake3 v0.1.0/go.mod h1:CCkLdzFrqf7xmxCdhQFvJsRRV2mwOLDoSPg6vUTB9Uk=
github.com/metacubex/chacha v0.1.5 h1:fKWMb/5c7ZrY8Uoqi79PPFxl+qwR7X/q0OrsAubyX2M=
github.com/metacubex/chacha v0.1.5/go.mod h1:Djn9bPZxLTXbJFSeyo0/qzEzQI+gUSSzttuzZM75GH8=
github.com/metacubex/chi v0.1.0 h1:rjNDyDj50nRpicG43CNkIw4ssiCbmDL8d7wJXKlUCsg=
github.com/metacubex/chi v0.1.0/go.mod h1:zM5u5oMQt8b2DjvDHvzadKrP6B2ztmasL1YHRMbVV+g=
github.com/metacubex/chi v0.1.1 h1:GajmCIXSIPAZNxqTTYpkCrkY9suBz9wjIdgPcoQ5K1A=
github.com/metacubex/chi v0.1.1/go.mod h1:/CvDXe8jZD/ecU8Y7fmS5XKLRR44UDmC6/IBkRIb6Z4=
github.com/metacubex/connect-ip-go v0.0.0-20260412152424-e1625567920a h1:Ph5UfTWDsGruZ+v95Df1ycTflQFmpZBFg2LUvj2kx/M=
github.com/metacubex/connect-ip-go v0.0.0-20260412152424-e1625567920a/go.mod h1:xYC8Ik7/rN6no+vTRuWMEziGwm3brA0wNM/zZP9qhOQ=
github.com/metacubex/cpu v0.1.1 h1:rRV5HGmeuGzjiKI3hYbL0dCd0qGwM7VUtk4ICXD06mI=
@@ -138,6 +138,10 @@ github.com/metacubex/hpke v0.1.0 h1:gu2jUNhraehWi0P/z5HX2md3d7L1FhPQE6/Q0E9r9xQ=
github.com/metacubex/hpke v0.1.0/go.mod h1:vfDm6gfgrwlXUxKDkWbcE44hXtmc1uxLDm2BcR11b3U=
github.com/metacubex/http v0.1.6 h1:xvXuvXMCMxCWMF5nEJF4yiKvXL+p2atWMzs37e80m1I=
github.com/metacubex/http v0.1.6/go.mod h1:Nxx0zZAo2AhRfanyL+fmmK6ACMtVsfpwIl1aFAik2Eg=
github.com/metacubex/jls-quic-go v0.0.0-20260717074316-85a8decdd355 h1:DD4qraoIY3IbkHxaBbpJR68sWNzrdKHfFRfFhT6m1W8=
github.com/metacubex/jls-quic-go v0.0.0-20260717074316-85a8decdd355/go.mod h1:dbfkDUKqohALRmgUrVIOLirCNfKjgaos9nfTnBRTpS8=
github.com/metacubex/jls-tls v0.0.0-20260716145614-4bf88db633e2 h1:n+qiR8opOJEerewq/S92zGoHBDXZb+dKmyYq0aKJtis=
github.com/metacubex/jls-tls v0.0.0-20260716145614-4bf88db633e2/go.mod h1:mmqs889W/TqPlfNRDa2UyJvRiLyiTJIEnWHkcj3SKB8=
github.com/metacubex/jsonv2 v0.0.0-20260518173308-f4597c22f1df h1:S0vBzqjXok24VopstOgPd1JdgglW9tXehrqvwpQWbQ8=
github.com/metacubex/jsonv2 v0.0.0-20260518173308-f4597c22f1df/go.mod h1:F4sVXat6QjPXkNsKRDyyG3BhSkxPFFnRPEIwmmyCgbg=
github.com/metacubex/kcp-go v0.0.0-20260105040817-550693377604 h1:hJwCVlE3ojViC35MGHB+FBr8TuIf3BUFn2EQ1VIamsI=
@@ -150,28 +154,26 @@ github.com/metacubex/nftables v0.0.0-20260426003805-208c2c1ba2cb h1:wk6mHYPURSUv
github.com/metacubex/nftables v0.0.0-20260426003805-208c2c1ba2cb/go.mod h1:73ZrCfhdkW4F2E2GAlta3km/S2RHhFNogCMtWZV2anQ=
github.com/metacubex/qpack v0.6.0 h1:YqClGIMOpiRYLjV1qOs483Od08MdPgRnHjt90FuaAKw=
github.com/metacubex/qpack v0.6.0/go.mod h1:lKGSi7Xk94IMvHGOmxS9eIei3bvIqpOAImEBsaOwTkA=
github.com/metacubex/quic-go v0.59.1-0.20260520020949-fcd18c7b6ace h1:KXacx7dp1GYVMgxezwXRt5BMsEbvAYuA6rPFUmdAvcQ=
github.com/metacubex/quic-go v0.59.1-0.20260520020949-fcd18c7b6ace/go.mod h1:2YEQEvFrZ5V76oynMBDTlN+4fdnSHCa2uNJxv3cm1HU=
github.com/metacubex/quic-go v0.59.1-0.20260606115121-0662b57ad5bf h1:WvIp5pF+LLZwg0I6555eMVlKFrLrqQqPKob6XW6niyo=
github.com/metacubex/quic-go v0.59.1-0.20260606115121-0662b57ad5bf/go.mod h1:2YEQEvFrZ5V76oynMBDTlN+4fdnSHCa2uNJxv3cm1HU=
github.com/metacubex/randv2 v0.2.0 h1:uP38uBvV2SxYfLj53kuvAjbND4RUDfFJjwr4UigMiLs=
github.com/metacubex/randv2 v0.2.0/go.mod h1:kFi2SzrQ5WuneuoLLCMkABtiBu6VRrMrWFqSPyj2cxY=
github.com/metacubex/restls-client-go v0.1.7 h1:eCwiXCTQb5WJu9IlgYvDBA1OgrINv58dEe7hcN5H15k=
github.com/metacubex/restls-client-go v0.1.7/go.mod h1:BN/U52vPw7j8VTSh2vleD/MnmVKCov84mS5VcjVHH4g=
github.com/metacubex/restls-client-go v0.1.8 h1:0kQ699TWnbK3bWLhCPE0oIiBJLN+errOLQ9Z3/P1lbA=
github.com/metacubex/restls-client-go v0.1.8/go.mod h1:BN/U52vPw7j8VTSh2vleD/MnmVKCov84mS5VcjVHH4g=
github.com/metacubex/sevenzip v1.6.4 h1:OIL+DeOeSAbKNsjqxcYUMiarRmX6Kaxakb0GT7E9Oik=
github.com/metacubex/sevenzip v1.6.4/go.mod h1:FP3X9bzFKj9wPxifGN9B3w2fIEicMjzKYIGIhnu+1pw=
github.com/metacubex/sing v0.5.7 h1:8OC+fhKFSv/l9ehEhJRaZZAOuthfZo68SteBVLe8QqM=
github.com/metacubex/sing v0.5.7/go.mod h1:ypf0mjwlZm0sKdQSY+yQvmsbWa0hNPtkeqyRMGgoN+w=
github.com/metacubex/sing-mux v0.3.9 h1:/aoBD2+sK2qsXDlNDe3hkR0GZuFDtwIZhOeGUx9W0Yk=
github.com/metacubex/sing-mux v0.3.9/go.mod h1:8bT7ZKT3clRrJjYc/x5CRYibC1TX/bK73a3r3+2E+Fc=
github.com/metacubex/sing-mux v0.3.10 h1:r5CuZ/KuwFsEcRRwpLvzLncW4fDzNfmSEcBEWcy/+94=
github.com/metacubex/sing-mux v0.3.10/go.mod h1:8bT7ZKT3clRrJjYc/x5CRYibC1TX/bK73a3r3+2E+Fc=
github.com/metacubex/sing-quic v0.0.0-20260527143057-68e10a6afdc3 h1:PnMby5+kZXTl/CFDHfxMbMTaSRD+uMKMsrDYVQyAmX8=
github.com/metacubex/sing-quic v0.0.0-20260527143057-68e10a6afdc3/go.mod h1:6ayFGfzzBE85csgQkM3gf4neFq6s0losHlPRSxY+nuk=
github.com/metacubex/sing-shadowsocks v0.2.12 h1:Wqzo8bYXrK5aWqxu/TjlTnYZzAKtKsaFQBdr6IHFaBE=
github.com/metacubex/sing-shadowsocks v0.2.12/go.mod h1:2e5EIaw0rxKrm1YTRmiMnDulwbGxH9hAFlrwQLQMQkU=
github.com/metacubex/sing-shadowsocks2 v0.2.7 h1:hSuuc0YpsfiqYqt1o+fP4m34BQz4e6wVj3PPBVhor3A=
github.com/metacubex/sing-shadowsocks2 v0.2.7/go.mod h1:vOEbfKC60txi0ca+yUlqEwOGc3Obl6cnSgx9Gf45KjE=
github.com/metacubex/sing-shadowtls v0.0.0-20260517015314-c11c36474edc h1:8wLoFfYQ88iGPL+krQ5tJsI8IAmkFjKpQL2q+y3pvss=
github.com/metacubex/sing-shadowtls v0.0.0-20260517015314-c11c36474edc/go.mod h1:mbfboaXauKJNIHJYxQRa+NJs4JU9NZfkA+I33dS2+9E=
github.com/metacubex/sing-tun v0.4.20 h1:xdupzizRoZKyDzP0l68WAx5Sk4ooiuT1GiWsiJyOGPw=
github.com/metacubex/sing-tun v0.4.20/go.mod h1:g4I/JNplDBhXLF+aQWgFbhNeJPSXQOWS9HvLeNvkgeA=
github.com/metacubex/sing-tun v0.4.21 h1:MjbPYytM240VH70lO182v2Sg/JthMmx6R6z6E2fCR2I=
github.com/metacubex/sing-tun v0.4.21/go.mod h1:g4I/JNplDBhXLF+aQWgFbhNeJPSXQOWS9HvLeNvkgeA=
github.com/metacubex/sing-vmess v0.2.5 h1:m9Zt5I27lB9fmLMZfism9sH2LcnAfShZfwSkf6/KJoE=
github.com/metacubex/sing-vmess v0.2.5/go.mod h1:AwtlzUgf8COe9tRYAKqWZ+leDH7p5U98a0ZUpYehl8Q=
github.com/metacubex/sing-wireguard v0.0.0-20260520151737-7e7c7c1b854c h1:tH9FuQW357zp2xAGzkoZTGpNGMVmEFZov0iV5M2S5ew=
@@ -180,16 +182,16 @@ github.com/metacubex/smux v0.0.0-20260105030934-d0c8756d3141 h1:DK2l6m2Fc85H2Bhi
github.com/metacubex/smux v0.0.0-20260105030934-d0c8756d3141/go.mod h1:/yI4OiGOSn0SURhZdJF3CbtPg3nwK700bG8TZLMBvAg=
github.com/metacubex/ssh v0.1.0 h1:iGfr99qk/eMHzUnQ/0bTxXT8+8SWqLSHBWDHoAhngzw=
github.com/metacubex/ssh v0.1.0/go.mod h1:NUtl0d+/f2cG9ECEpMM8iCVOpmggQlC13oLeDUONDlU=
github.com/metacubex/tailscale v0.0.0-20260520011538-f23132fac4b7 h1:LoJR4NMyNKHeEJoeGDtcsao7sV0NRkzMeV5H/0J0MIE=
github.com/metacubex/tailscale v0.0.0-20260520011538-f23132fac4b7/go.mod h1:MAo3HhE7968rIwmDvYTYE8xCsV4x+hLnkChdXeP3X4c=
github.com/metacubex/tailscale-wireguard-go v0.0.0-20260521124654-e1bf77ef79af h1:c60IbBMUq2h1M2m7+grMJJmBmrObxL8SwvNtm6Ozbwk=
github.com/metacubex/tailscale-wireguard-go v0.0.0-20260521124654-e1bf77ef79af/go.mod h1:i3zLKytWkOnyT1i9OmiLevWvrN5J5HE1+yjE7UYNfcQ=
github.com/metacubex/tfo-go v0.0.0-20251130171125-413e892ac443 h1:H6TnfM12tOoTizYE/qBHH3nEuibIelmHI+BVSxVJr8o=
github.com/metacubex/tfo-go v0.0.0-20251130171125-413e892ac443/go.mod h1:l9oLnLoEXyGZ5RVLsh7QCC5XsouTUyKk4F2nLm2DHLw=
github.com/metacubex/tls v0.1.6 h1:t2ubLneYa4ceyIC++54a57BLqZFA/QYUrhdjLk2GPwo=
github.com/metacubex/tls v0.1.6/go.mod h1:0XeVdL0cBw+8i5Hqy3lVeP9IyD/LFTq02ExvHM6rzEM=
github.com/metacubex/utls v1.8.4 h1:HmL9nUApDdWSkgUyodfwF6hSjtiwCGGdyhaSpEejKpg=
github.com/metacubex/utls v1.8.4/go.mod h1:kncGGVhFaoGn5M3pFe3SXhZCzsbCJayNOH4UEqTKTko=
github.com/metacubex/tailscale v0.0.0-20260711142031-e2257fe61058 h1:TFM/yl2XvyMDQY16GHDxXtuJMa8DGz7roRpY6plxLMk=
github.com/metacubex/tailscale v0.0.0-20260711142031-e2257fe61058/go.mod h1:rDpd7cy7itpPi3Kgb90fUgSnKwMXHYBk0R5DuNRiNNA=
github.com/metacubex/tailscale-wireguard-go v0.0.0-20260623093519-06ea214022e4 h1:ui12xOUPsCAYwzI7+7Hw2mVhrAbWDGNQZcrBiELhGk0=
github.com/metacubex/tailscale-wireguard-go v0.0.0-20260623093519-06ea214022e4/go.mod h1:W00EeoPdd2jbV3+ZUa0m2b7FdKx5B1LFsXvEpy7cToQ=
github.com/metacubex/tfo-go v0.0.0-20260623020846-376a77860b8c h1:+2C1UshSLzaogLY/IDz1XhOLxjhs3rkhVHB4eCN9Zto=
github.com/metacubex/tfo-go v0.0.0-20260623020846-376a77860b8c/go.mod h1:l9oLnLoEXyGZ5RVLsh7QCC5XsouTUyKk4F2nLm2DHLw=
github.com/metacubex/tls v0.1.7 h1:7YW+7z2xGH1bozSg04q3gKNd7GBSiTSXIrQY2SMnfoE=
github.com/metacubex/tls v0.1.7/go.mod h1:0XeVdL0cBw+8i5Hqy3lVeP9IyD/LFTq02ExvHM6rzEM=
github.com/metacubex/utls v1.8.7 h1:Cp+yWkNTFkSihETgGWq34hlVFds5HpYWVOR1xovUVTs=
github.com/metacubex/utls v1.8.7/go.mod h1:kncGGVhFaoGn5M3pFe3SXhZCzsbCJayNOH4UEqTKTko=
github.com/metacubex/wireguard-go v0.0.0-20250820062549-a6cecdd7f57f h1:FGBPRb1zUabhPhDrlKEjQ9lgIwQ6cHL4x8M9lrERhbk=
github.com/metacubex/wireguard-go v0.0.0-20250820062549-a6cecdd7f57f/go.mod h1:oPGcV994OGJedmmxrcK9+ni7jUEMGhR+uVQAdaduIP4=
github.com/metacubex/yamux v0.0.0-20250918083631-dd5f17c0be49 h1:lhlqpYHopuTLx9xQt22kSA9HtnyTDmk5XjjQVCGHe2E=
+90 -137
View File
@@ -3,7 +3,6 @@ package main
import (
"cmp"
"context"
"encoding/json"
"github.com/metacubex/mihomo/adapter"
"github.com/metacubex/mihomo/adapter/outboundgroup"
"github.com/metacubex/mihomo/common/observable"
@@ -23,30 +22,27 @@ import (
"golang.org/x/exp/slices"
"net"
"os"
"path/filepath"
"runtime"
"runtime/debug"
"strconv"
"sync/atomic"
"time"
)
var (
isInit = false
isInit atomic.Bool
externalProviders = map[string]cp.Provider{}
logSubscriber observable.Subscription[log.Event]
)
func handleInitClash(paramsString string) bool {
func handleInitClash(params *InitParams) bool {
runLock.Lock()
defer runLock.Unlock()
var params = InitParams{}
err := json.Unmarshal([]byte(paramsString), &params)
if err != nil {
return false
}
version = params.Version
constant.SetHomeDir(params.HomeDir)
isInit = true
return isInit
isInit.Store(true)
return true
}
func handleStartListener() bool {
@@ -68,7 +64,7 @@ func handleStopListener() bool {
}
func handleGetIsInit() bool {
return isInit
return isInit.Load()
}
func handleForceGC() {
@@ -83,7 +79,7 @@ func handleShutdown() bool {
stopListeners()
executor.Shutdown()
handleForceGC()
isInit = false
isInit.Store(false)
return true
}
@@ -136,25 +132,23 @@ func handleGetProxies() ProxiesData {
}
}
func handleChangeProxy(data string, fn func(string string)) {
func handleChangeProxy(params *ChangeProxyParams, fn func(string string)) {
runLock.Lock()
go func() {
defer runLock.Unlock()
var params = &ChangeProxyParams{}
err := json.Unmarshal([]byte(data), params)
if err != nil {
fn(err.Error())
return
}
groupName := *params.GroupName
proxyName := *params.ProxyName
groupName := params.GroupName
proxyName := params.ProxyName
proxies := tunnel.AllProxies()
group, ok := proxies[groupName]
if !ok {
fn("Not found group")
return
}
adapterProxy := group.(*adapter.Proxy)
adapterProxy, ok := group.(*adapter.Proxy)
if !ok {
fn("Group has invalid proxy type")
return
}
selector, ok := adapterProxy.ProxyAdapter.(outboundgroup.SelectAble)
if !ok {
fn("Group is not selectable")
@@ -163,11 +157,11 @@ func handleChangeProxy(data string, fn func(string string)) {
if proxyName == "" {
selector.ForceSet(proxyName)
} else {
err = selector.Set(proxyName)
}
if err != nil {
fn(err.Error())
return
err := selector.Set(proxyName)
if err != nil {
fn(err.Error())
return
}
}
fn("")
@@ -175,50 +169,42 @@ func handleChangeProxy(data string, fn func(string string)) {
}()
}
func handleGetTraffic(onlyStatisticsProxy bool) string {
func handleGetTraffic(onlyStatisticsProxy bool) Traffic {
up, down := statistic.DefaultManager.NowTraffic(onlyStatisticsProxy)
traffic := map[string]int64{
"up": up,
"down": down,
return Traffic{
Up: up,
Down: down,
}
data, err := json.Marshal(traffic)
if err != nil {
logError("Error: %s", err)
return ""
}
return string(data)
}
func handleGetTotalTraffic(onlyStatisticsProxy bool) string {
func handleGetTotalTraffic(onlyStatisticsProxy bool) Traffic {
up, down := statistic.DefaultManager.TotalTraffic(onlyStatisticsProxy)
traffic := map[string]int64{
"up": up,
"down": down,
return Traffic{
Up: up,
Down: down,
}
data, err := json.Marshal(traffic)
if err != nil {
logError("Error: %s", err)
return ""
}
return string(data)
}
func handleResetTraffic() {
statistic.DefaultManager.ResetStatistic()
}
func handleAsyncTestDelay(paramsString string, fn func(string)) {
mBatch.Go(paramsString, func() (bool, error) {
var params = &TestDelayParams{}
err := json.Unmarshal([]byte(paramsString), params)
if err != nil {
fn("")
return false, nil
func handleAsyncTestDelay(params *TestDelayParams, fn func(*Delay)) {
batchKey := params.ProxyName + "\x00" + params.TestUrl
mBatch.Go(batchKey, func() (bool, error) {
testUrl := params.TestUrl
if testUrl == "" {
testUrl = constant.DefaultTestURL
}
delayData := &Delay{
Name: params.ProxyName,
Url: testUrl,
Value: -1,
}
expectedStatus, err := utils.NewUnsignedRanges[uint16]("")
if err != nil {
fn("")
fn(delayData)
return false, nil
}
@@ -228,48 +214,26 @@ func handleAsyncTestDelay(paramsString string, fn func(string)) {
proxies := tunnel.AllProxies()
proxy := proxies[params.ProxyName]
delayData := &Delay{
Name: params.ProxyName,
}
if proxy == nil {
delayData.Value = -1
data, _ := json.Marshal(delayData)
fn(string(data))
fn(delayData)
return false, nil
}
testUrl := constant.DefaultTestURL
if params.TestUrl != "" {
testUrl = params.TestUrl
}
delayData.Url = testUrl
delay, err := proxy.URLTest(ctx, testUrl, expectedStatus)
if err != nil || delay == 0 {
delayData.Value = -1
data, _ := json.Marshal(delayData)
fn(string(data))
fn(delayData)
return false, nil
}
delayData.Value = int32(delay)
data, _ := json.Marshal(delayData)
fn(string(data))
fn(delayData)
return false, nil
})
}
func handleGetConnections() string {
func handleGetConnections() *statistic.Snapshot {
runLock.Lock()
defer runLock.Unlock()
snapshot := statistic.DefaultManager.Snapshot()
data, err := json.Marshal(snapshot)
if err != nil {
logError("Error: %s", err)
return ""
}
return string(data)
return statistic.DefaultManager.Snapshot()
}
func handleCloseConnections() bool {
@@ -307,7 +271,7 @@ func handleCloseConnection(connectionId string) bool {
return true
}
func handleGetExternalProviders() string {
func handleGetExternalProviders() []ExternalProvider {
runLock.Lock()
defer runLock.Unlock()
externalProviders = getExternalProvidersRaw()
@@ -322,29 +286,21 @@ func handleGetExternalProviders() string {
slices.SortFunc(eps, func(a, b ExternalProvider) int {
return cmp.Compare(a.Name, b.Name)
})
data, err := json.Marshal(eps)
if err != nil {
return ""
}
return string(data)
return eps
}
func handleGetExternalProvider(externalProviderName string) string {
func handleGetExternalProvider(externalProviderName string) *ExternalProvider {
runLock.Lock()
defer runLock.Unlock()
externalProvider, exist := externalProviders[externalProviderName]
if !exist {
return ""
return nil
}
e, err := toExternalProvider(externalProvider)
if err != nil {
return ""
return nil
}
data, err := json.Marshal(e)
if err != nil {
return ""
}
return string(data)
return e
}
func handleUpdateGeoData(geoType string) {
@@ -368,6 +324,8 @@ func handleUpdateGeoData(geoType string) {
func handleUpdateExternalProvider(providerName string, fn func(value string)) {
go func() {
runLock.Lock()
defer runLock.Unlock()
externalProvider, exist := externalProviders[providerName]
if !exist {
fn("external provider is not exist")
@@ -410,13 +368,15 @@ func handleSuspend(suspended bool) bool {
}
func handleStartLog() {
runLock.Lock()
if logSubscriber != nil {
log.UnSubscribe(logSubscriber)
logSubscriber = nil
}
logSubscriber = log.Subscribe()
subscriber := log.Subscribe()
logSubscriber = subscriber
runLock.Unlock()
go func() {
for logData := range logSubscriber {
for logData := range subscriber {
if logData.LogLevel < log.Level() {
continue
}
@@ -430,6 +390,8 @@ func handleStartLog() {
}
func handleStopLog() {
runLock.Lock()
defer runLock.Unlock()
if logSubscriber != nil {
log.UnSubscribe(logSubscriber)
logSubscriber = nil
@@ -449,9 +411,9 @@ func handleGetCountryCode(ip string, fn func(value string)) {
}()
}
func handleGetMemory(fn func(value string)) {
func handleGetMemory(fn func(value uint64)) {
go func() {
fn(strconv.FormatUint(statistic.DefaultManager.Memory(), 10))
fn(statistic.DefaultManager.Memory())
}()
}
@@ -471,55 +433,46 @@ func handleCrash() {
panic("handle invoke crash")
}
func handleUpdateConfig(bytes []byte) string {
var params = &UpdateParams{}
err := json.Unmarshal(bytes, params)
if err != nil {
return err.Error()
}
func handleUpdateConfig(params *UpdateParams) string {
updateConfig(params)
return ""
}
func handleDelFile(path string, result ActionResult) {
// handleClearEffect derives the provider directory from a profile ID so the
// method cannot be used as a general-purpose privileged file deletion API.
func handleClearEffect(profileId int64, response MethodResponse) {
go func() {
fileInfo, err := os.Stat(path)
if err != nil {
if !os.IsNotExist(err) {
result.success(err.Error())
}
result.success("")
if !isInit.Load() {
response.success("not initialized")
return
}
if fileInfo.IsDir() {
err = os.RemoveAll(path)
if err != nil {
result.success(err.Error())
return
}
} else {
err = os.Remove(path)
if err != nil {
result.success(err.Error())
return
}
if profileId <= 0 {
response.success("invalid profile id")
return
}
result.success("")
providersRoot := filepath.Join(
constant.Path.HomeDir(),
"profiles",
"providers",
)
providersPath := filepath.Join(
providersRoot,
strconv.FormatInt(profileId, 10),
)
if err := os.RemoveAll(providersPath); err != nil {
response.success(err.Error())
return
}
_ = os.Remove(providersRoot)
response.success("")
}()
}
func handleSetupConfig(bytes []byte) string {
if !isInit {
func handleSetupConfig(params *SetupParams) string {
if !isInit.Load() {
return "not initialized"
}
var params = defaultSetupParams()
err := UnmarshalJson(bytes, params)
if err != nil {
logError("unmarshalRawConfig error %v", err)
_ = applyConfig(defaultSetupParams())
return err.Error()
}
err = applyConfig(params)
err := applyConfig(params)
if err != nil {
return err.Error()
}
+50 -34
View File
@@ -159,43 +159,44 @@ func handleUpdateDns(value string) {
}()
}
func (result ActionResult) send() {
data, err := result.Json()
func (response MethodResponse) send() {
data, err := response.JSON()
if err != nil {
return
}
invokeResult(result.callback, string(data))
if result.Method != messageMethod {
releaseObject(result.callback)
}
invokeResult(response.callback, string(data))
releaseObject(response.callback)
}
func nextHandle(action *Action, result ActionResult) bool {
switch action.Method {
func handlePlatformMethodCall(call *MethodCall, response MethodResponse) bool {
switch call.Method {
case updateDnsMethod:
data := action.Data.(string)
handleUpdateDns(data)
result.success(true)
value := ""
if !decodeMethodArguments(call, response, &value) {
return true
}
handleUpdateDns(value)
response.success(true)
return true
}
return false
}
//export invokeAction
func invokeAction(callback unsafe.Pointer, paramsChar *C.char) {
//export invokeMethod
func invokeMethod(callback unsafe.Pointer, paramsChar *C.char) {
params := takeCString(paramsChar)
var action = &Action{}
err := json.Unmarshal([]byte(params), action)
call := &MethodCall{}
err := json.Unmarshal([]byte(params), call)
if err != nil {
invokeResult(callback, err.Error())
response := MethodResponse{callback: callback}
response.failure("invalid_method_call", err.Error(), nil)
return
}
result := ActionResult{
Id: action.Id,
Method: action.Method,
response := MethodResponse{
ID: call.ID,
callback: callback,
}
go handleAction(action, result)
go handleMethodCall(call, response)
}
//export startTUN
@@ -212,14 +213,21 @@ func startTUN(callback unsafe.Pointer, fd C.int, stackChar, addressChar, dnsChar
//export quickSetup
func quickSetup(callback unsafe.Pointer, initParamsChar *C.char, setupParamsChar *C.char) {
go func() {
defer releaseObject(callback)
initParamsString := takeCString(initParamsChar)
setupParamsString := takeCString(setupParamsChar)
if !handleInitClash(initParamsString) {
initParams := InitParams{}
if err := json.Unmarshal([]byte(initParamsString), &initParams); err != nil || !handleInitClash(&initParams) {
invokeResult(callback, "init failed")
return
}
isRunning = true
message := handleSetupConfig([]byte(setupParamsString))
setupParams := defaultSetupParams()
if err := UnmarshalJson([]byte(setupParamsString), setupParams); err != nil {
invokeResult(callback, err.Error())
return
}
message := handleSetupConfig(setupParams)
invokeResult(callback, message)
}()
}
@@ -234,28 +242,36 @@ func setEventListener(listener unsafe.Pointer) {
//export getTotalTraffic
func getTotalTraffic(onlyStatisticsProxy bool) *C.char {
data := C.CString(handleGetTotalTraffic(onlyStatisticsProxy))
defer C.free(unsafe.Pointer(data))
return data
return C.CString(marshalResult(handleGetTotalTraffic(onlyStatisticsProxy)))
}
//export getTraffic
func getTraffic(onlyStatisticsProxy bool) *C.char {
data := C.CString(handleGetTraffic(onlyStatisticsProxy))
defer C.free(unsafe.Pointer(data))
return data
return C.CString(marshalResult(handleGetTraffic(onlyStatisticsProxy)))
}
func sendMessage(message Message) {
func marshalResult(value any) string {
data, err := json.Marshal(value)
if err != nil {
logError("Result marshal error: %v", err)
return ""
}
return string(data)
}
func sendMessageBatch(messages []Message) {
if eventListener == nil {
return
}
result := ActionResult{
Method: messageMethod,
callback: eventListener,
Data: message,
call := MethodCall{
Method: messageMethod,
Arguments: mustMarshalJSON(messages),
}
result.send()
data, err := json.Marshal(call)
if err != nil {
return
}
invokeResult(eventListener, string(data))
}
//export stopTun
+102
View File
@@ -0,0 +1,102 @@
package main
import "time"
const (
messageBatchInterval = 16 * time.Millisecond
messageBatchSize = 32
messageQueueSize = 256
)
var (
priorityMessageQueue = make(chan Message, messageQueueSize)
bulkMessageQueue = make(chan Message, messageQueueSize)
)
func init() {
go runMessageBatcher(priorityMessageQueue, bulkMessageQueue, sendMessageBatch)
}
func sendMessage(message Message) {
queue := priorityMessageQueue
if message.Type == LogMessage || message.Type == RequestMessage {
queue = bulkMessageQueue
}
enqueueLatest(queue, message)
}
func enqueueLatest(queue chan Message, message Message) {
select {
case queue <- message:
return
default:
}
// Event delivery must never block the core. Each priority class evicts only
// its own oldest event, so log or request floods cannot displace state.
select {
case <-queue:
default:
}
select {
case queue <- message:
default:
}
}
func runMessageBatcher(
priorityMessages <-chan Message,
bulkMessages <-chan Message,
send func([]Message),
) {
ticker := time.NewTicker(messageBatchInterval)
defer ticker.Stop()
batch := make([]Message, 0, messageBatchSize)
flush := func() {
if len(batch) == 0 {
return
}
current := append([]Message(nil), batch...)
batch = batch[:0]
send(current)
}
appendMessage := func(message Message) {
batch = append(batch, message)
if len(batch) >= messageBatchSize {
flush()
}
}
for priorityMessages != nil || bulkMessages != nil {
// Prefer state-bearing events whenever both queues have work.
select {
case message, ok := <-priorityMessages:
if !ok {
priorityMessages = nil
} else {
appendMessage(message)
}
continue
default:
}
select {
case message, ok := <-priorityMessages:
if !ok {
priorityMessages = nil
} else {
appendMessage(message)
}
case message, ok := <-bulkMessages:
if !ok {
bulkMessages = nil
} else {
appendMessage(message)
}
case <-ticker.C:
flush()
}
}
flush()
}
+284
View File
@@ -0,0 +1,284 @@
package main
import (
"encoding/json"
"fmt"
"runtime"
"unsafe"
)
type MethodCall struct {
ID string `json:"id,omitempty"`
Method CoreMethod `json:"method"`
Arguments json.RawMessage `json:"arguments"`
}
func (call MethodCall) decodeArguments(target any) error {
if len(call.Arguments) == 0 || string(call.Arguments) == "null" {
return fmt.Errorf("missing arguments")
}
return json.Unmarshal(call.Arguments, target)
}
func decodeMethodArguments(call *MethodCall, response MethodResponse, target any) bool {
if err := call.decodeArguments(target); err != nil {
response.failure(
"invalid_arguments",
fmt.Sprintf("invalid arguments for %s: %v", call.Method, err),
nil,
)
return false
}
return true
}
type MethodError struct {
Code string `json:"code"`
Message string `json:"message"`
Details any `json:"details"`
}
type MethodResponse struct {
ID string `json:"id,omitempty"`
Result any `json:"result"`
Error *MethodError `json:"error,omitempty"`
callback unsafe.Pointer
}
func mustMarshalJSON(value any) json.RawMessage {
data, err := json.Marshal(value)
if err != nil {
panic(err)
}
return data
}
func (response MethodResponse) JSON() ([]byte, error) {
return json.Marshal(response)
}
func (response MethodResponse) success(result any) {
response.Result = result
response.Error = nil
response.send()
}
func (response MethodResponse) failure(code, message string, details any) {
response.Result = nil
response.Error = &MethodError{
Code: code,
Message: message,
Details: details,
}
response.send()
}
func (response MethodResponse) notImplemented(method CoreMethod) {
response.failure(
"not_implemented",
fmt.Sprintf("unknown method: %s", method),
nil,
)
}
func handleMethodCall(call *MethodCall, response MethodResponse) {
// The crash method is a developer-only fatal-path test. It must bypass the
// recovery below so the core process terminates; on Android this also
// terminates the in-process application.
if call.Method == crashMethod {
handleCrash()
return
}
defer func() {
if r := recover(); r != nil {
buf := make([]byte, 4096)
n := runtime.Stack(buf, false)
logError("panic in handleMethodCall(%s): %v\n%s", call.Method, r, buf[:n])
response.failure("internal_error", fmt.Sprintf("internal panic: %v", r), nil)
}
}()
switch call.Method {
case initClashMethod:
params := InitParams{}
if !decodeMethodArguments(call, response, &params) {
return
}
response.success(handleInitClash(&params))
return
case getIsInitMethod:
response.success(handleGetIsInit())
return
case forceGcMethod:
handleForceGC()
response.success(true)
return
case shutdownMethod:
response.success(handleShutdown())
return
case validateConfigMethod:
path := ""
if !decodeMethodArguments(call, response, &path) {
return
}
response.success(handleValidateConfig(path))
return
case updateConfigMethod:
params := UpdateParams{}
if !decodeMethodArguments(call, response, &params) {
return
}
response.success(handleUpdateConfig(&params))
return
case setupConfigMethod:
params := defaultSetupParams()
if !decodeMethodArguments(call, response, params) {
return
}
response.success(handleSetupConfig(params))
return
case getProxiesMethod:
response.success(handleGetProxies())
return
case changeProxyMethod:
params := ChangeProxyParams{}
if !decodeMethodArguments(call, response, &params) {
return
}
handleChangeProxy(&params, func(value string) {
response.success(value)
})
return
case getTrafficMethod:
onlyStatisticsProxy := false
if !decodeMethodArguments(call, response, &onlyStatisticsProxy) {
return
}
response.success(handleGetTraffic(onlyStatisticsProxy))
return
case getTotalTrafficMethod:
onlyStatisticsProxy := false
if !decodeMethodArguments(call, response, &onlyStatisticsProxy) {
return
}
response.success(handleGetTotalTraffic(onlyStatisticsProxy))
return
case resetTrafficMethod:
handleResetTraffic()
response.success(true)
return
case asyncTestDelayMethod:
params := TestDelayParams{}
if !decodeMethodArguments(call, response, &params) {
return
}
handleAsyncTestDelay(&params, func(value *Delay) {
response.success(value)
})
return
case getConnectionsMethod:
response.success(handleGetConnections())
return
case closeConnectionsMethod:
response.success(handleCloseConnections())
return
case resetConnectionsMethod:
response.success(handleResetConnections())
return
case getConfigMethod:
path := ""
if !decodeMethodArguments(call, response, &path) {
return
}
config, err := handleGetConfig(path)
if err != nil {
response.failure("core_error", err.Error(), nil)
return
}
response.success(config)
return
case closeConnectionMethod:
id := ""
if !decodeMethodArguments(call, response, &id) {
return
}
response.success(handleCloseConnection(id))
return
case getExternalProvidersMethod:
response.success(handleGetExternalProviders())
return
case getExternalProviderMethod:
externalProviderName := ""
if !decodeMethodArguments(call, response, &externalProviderName) {
return
}
response.success(handleGetExternalProvider(externalProviderName))
return
case updateGeoDataMethod:
geoType := ""
if !decodeMethodArguments(call, response, &geoType) {
return
}
handleUpdateGeoData(geoType)
response.success("")
return
case updateExternalProviderMethod:
providerName := ""
if !decodeMethodArguments(call, response, &providerName) {
return
}
handleUpdateExternalProvider(providerName, func(value string) {
response.success(value)
})
return
case sideLoadExternalProviderMethod:
params := map[string]string{}
if !decodeMethodArguments(call, response, &params) {
return
}
providerName := params["providerName"]
data := params["data"]
handleSideLoadExternalProvider(providerName, []byte(data), func(value string) {
response.success(value)
})
return
case startLogMethod:
handleStartLog()
response.success(true)
return
case stopLogMethod:
handleStopLog()
response.success(true)
return
case startListenerMethod:
response.success(handleStartListener())
return
case stopListenerMethod:
response.success(handleStopListener())
return
case getCountryCodeMethod:
ip := ""
if !decodeMethodArguments(call, response, &ip) {
return
}
handleGetCountryCode(ip, func(value string) {
response.success(value)
})
return
case getMemoryMethod:
handleGetMemory(func(value uint64) {
response.success(value)
})
return
case clearEffectMethod:
var profileId int64
if !decodeMethodArguments(call, response, &profileId) {
return
}
handleClearEffect(profileId, response)
return
default:
if !handlePlatformMethodCall(call, response) {
response.notImplemented(call.Method)
}
}
}
+49 -21
View File
@@ -5,6 +5,7 @@ package main
import (
"encoding/binary"
"encoding/json"
"fmt"
"io"
"sync"
)
@@ -14,29 +15,54 @@ var (
connMu sync.Mutex
)
func (result ActionResult) send() {
data, err := result.Json()
const maxIPCFrameSize = 64 * 1024 * 1024
func (response MethodResponse) send() {
data, err := response.JSON()
if err != nil {
logError("ActionResult marshal error: method=%s id=%s err=%v", result.Method, result.Id, err)
logError("MethodResponse marshal error: id=%s err=%v", response.ID, err)
return
}
send(data)
}
func sendMessage(message Message) {
result := ActionResult{
Method: messageMethod,
Data: message,
func sendMessageBatch(messages []Message) {
call := MethodCall{
Method: messageMethod,
Arguments: mustMarshalJSON(messages),
}
result.send()
data, err := json.Marshal(call)
if err != nil {
logError("MethodCall marshal error: method=%s err=%v", call.Method, err)
return
}
send(data)
}
func writeFrame(w io.Writer, data []byte) error {
frame := make([]byte, 4+len(data))
binary.LittleEndian.PutUint32(frame, uint32(len(data)))
copy(frame[4:], data)
_, err := w.Write(frame)
return err
if len(data) > maxIPCFrameSize {
return fmt.Errorf("IPC frame exceeds %d bytes", maxIPCFrameSize)
}
lenBuf := [4]byte{}
binary.LittleEndian.PutUint32(lenBuf[:], uint32(len(data)))
if err := writeAll(w, lenBuf[:]); err != nil {
return err
}
return writeAll(w, data)
}
func writeAll(w io.Writer, data []byte) error {
for len(data) > 0 {
n, err := w.Write(data)
if err != nil {
return err
}
if n == 0 {
return io.ErrShortWrite
}
data = data[n:]
}
return nil
}
func readFrame(r io.Reader) ([]byte, error) {
@@ -45,7 +71,10 @@ func readFrame(r io.Reader) ([]byte, error) {
return nil, err
}
length := binary.LittleEndian.Uint32(lenBuf)
data := make([]byte, length)
if length > maxIPCFrameSize {
return nil, fmt.Errorf("IPC frame exceeds %d bytes", maxIPCFrameSize)
}
data := make([]byte, int(length))
if _, err := io.ReadFull(r, data); err != nil {
return nil, err
}
@@ -83,24 +112,23 @@ func startServer(arg string) {
}
return
}
var action = &Action{}
call := &MethodCall{}
err = json.Unmarshal(data, action)
err = json.Unmarshal(data, call)
if err != nil {
logError("server unmarshal error: %v (data: %q)", err, data)
continue
}
result := ActionResult{
Id: action.Id,
Method: action.Method,
response := MethodResponse{
ID: call.ID,
}
go handleAction(action, result)
go handleMethodCall(call, response)
}
}
func nextHandle(action *Action, result ActionResult) bool {
func handlePlatformMethodCall(call *MethodCall, response MethodResponse) bool {
return false
}
+10 -10
View File
@@ -4,43 +4,43 @@ import 'package:flutter/material.dart';
extension ColorExtension on Color {
Color get opacity80 {
return withAlpha(204);
return withValues(alpha: 0.8);
}
Color get opacity60 {
return withAlpha(153);
return withValues(alpha: 0.6);
}
Color get opacity50 {
return withAlpha(128);
return withValues(alpha: 0.5);
}
Color get opacity38 {
return withAlpha(97);
return withValues(alpha: 0.38);
}
Color get opacity30 {
return withAlpha(77);
return withValues(alpha: 0.3);
}
Color get opacity12 {
return withAlpha(31);
return withValues(alpha: 0.12);
}
Color get opacity15 {
return withAlpha(38);
return withValues(alpha: 0.15);
}
Color get opacity10 {
return withAlpha(15);
return withValues(alpha: 0.1);
}
Color get opacity3 {
return withAlpha(76);
return withValues(alpha: 0.03);
}
Color get opacity0 {
return withAlpha(0);
return withValues(alpha: 0);
}
int get value32bit {
-1
View File
@@ -20,7 +20,6 @@ export 'launch.dart';
export 'link.dart';
export 'lock.dart';
export 'measure.dart';
export 'migration.dart';
export 'mixin.dart';
export 'navigation.dart';
export 'navigator.dart';
+11 -1
View File
@@ -16,8 +16,10 @@ const browserUa =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
const packageName = 'com.follow.clash';
final unixSocketPath = '/tmp/FlClashSocket_${Random().nextInt(10000)}.sock';
final windowsPipeName = '\\\\.\\pipe\\FlClashCore_${Random().nextInt(10000)}';
final windowsPipeName = '\\\\.\\pipe\\FlClashCore_${_randomPipeId()}';
const helperPort = 47890;
const helperProtocolVersionHeader = 'x-flclash-helper-protocol';
const helperProtocolVersion = '4';
const maxTextScale = 1.4;
const minTextScale = 0.8;
final baseInfoEdgeInsets = EdgeInsets.symmetric(
@@ -34,6 +36,14 @@ const sheetAppBarHeight = 68.0;
const watchExecution = false;
String _randomPipeId() {
final random = Random.secure();
return List.generate(
16,
(_) => random.nextInt(256).toRadixString(16).padLeft(2, '0'),
).join();
}
final defaultTextScaleFactor =
WidgetsBinding.instance.platformDispatcher.textScaleFactor;
const httpTimeoutDuration = Duration(milliseconds: 5000);

Some files were not shown because too many files have changed in this diff Show More