Compare commits

...

30 Commits

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

Optimize more details
2026-07-28 13:21:48 +08:00
chen08209 7c831855ef Update changelog 2026-07-11 12:35:22 +00:00
chen08209 7e7f1f89f3 Fix macos performance issue
Support custom global-ua

Update core

Optimize some details
2026-07-11 20:13:36 +08:00
BIGGASSS 5179d55dfb Fix linux silent launching not working 2026-07-11 19:12:15 +08:00
chen08209 ac2f6b919e Update changelog 2026-05-29 06:55:29 +00:00
chen08209 45015f856b Support custom overwrite
Support run on demand

Optimize windows ipc

Optimize windows arm64

Optimize build

Optimize some details

Update core
2026-05-29 14:35:56 +08:00
chen08209 672eaccd35 Update changelog 2026-02-02 02:38:00 +00:00
chen08209 2fbb96f5c1 Add sqlite store
Optimize android quick action

Optimize backup and restore

Optimize more details
2026-02-02 10:15:42 +08:00
chen08209 243b3037d9 Update changelog 2025-12-12 06:52:14 +00:00
chen08209 6e404ab19c Fix windows some issues
Optimize overwrite handle

Optimize access control page

Optimize some details
2025-12-12 14:33:03 +08:00
chen08209 2395a4b20c Update changelog 2025-10-08 08:41:35 +00:00
chen08209 d3c3f04062 Fix android tile service
Support append system DNS

Fix some issues
2025-10-08 16:28:02 +08:00
chen08209 201062dc5d Update changelog 2025-09-27 07:31:55 +00:00
chen08209 45b163184d Fix some issues
Optimize Windows service mode

Update core
2025-09-27 15:17:19 +08:00
chen08209 2ab70f193a Update changelog 2025-09-23 07:44:50 +00:00
chen08209 ed7868282a Add android separates the core process
Support core status check and force restart

Optimize proxies page and access page

Update flutter and pub dependencies

Update go version

Optimize more details
2025-09-23 15:23:58 +08:00
chen08209 e956373ef4 Update changelog 2025-07-29 02:57:43 +00:00
chen08209 1154e7b245 Optimize desktop view
Optimize logs, requests, connection pages

Optimize windows tray auto hide

Optimize some details

Update core
2025-07-29 10:43:05 +08:00
chen08209 adb890d763 Update changelog 2025-06-15 10:59:15 +00:00
chen08209 1477f9bd9c Fix windows tun issues
Optimize android get system dns

Optimize more details
2025-06-15 18:44:19 +08:00
chen08209 a06e813249 Update changelog 2025-06-07 16:08:38 +00:00
chen08209 afbc5adb05 Support override script
Support proxies search

Support svg display

Optimize config persistence

Add some scenes auto close connections

Update core

Optimize more details
2025-06-07 23:52:27 +08:00
chen08209 76c9f08d4a Fix issues that TUN repeat failed to open. 2025-05-01 22:12:05 +08:00
chen08209 f83a8e0cce Update changelog 2025-05-01 13:03:36 +00:00
chen08209 f5544f1af7 Fix windows service verify issues 2025-05-01 20:45:23 +08:00
chen08209 eeb543780a Update changelog 2025-04-30 16:20:40 +00:00
chen08209 676f2d058a Add windows server mode start process verify
Add linux deb dependencies

Add backup recovery strategy select

Support custom text scaling

Optimize the display of different text scale

Optimize windows setup experience

Optimize startTun performance

Optimize android tv experience

Optimize default option

Optimize computed text size

Optimize hyperOS freeform window

Add developer mode

Update core

Optimize more details
2025-05-01 00:02:29 +08:00
chen08209 fb9d0cb22c Add issues template 2025-04-19 22:57:07 +08:00
chen08209 e7eb312254 Update changelog 2025-04-18 09:09:33 +00:00
chen08209 c9cd80bcb3 Optimize android vpn performance
Add custom primary color and color scheme

Add linux nad windows arm release

Optimize requests and logs page
2025-04-18 16:54:05 +08:00
736 changed files with 160002 additions and 95578 deletions
+28
View File
@@ -0,0 +1,28 @@
# Agent Configuration Model
Use the smallest durable surface that matches the job.
## Surfaces
- `AGENTS.md`: auto-loaded repository entry point. Keep it small and reserve it for always-on rules, routing, and high-priority expectations.
- `.agents/*.md`: human- and agent-readable reference docs linked from `AGENTS.md`. Use these for detailed project context, commands, architecture, and conventions.
- `.agents/skills/*/SKILL.md`: repo-scoped Codex skills. Use these for repeatable workflows that should be discoverable by name and description.
- `.codex/config.toml`: trusted project Codex settings such as MCP, hooks, sandbox, approval, or model defaults.
- `.codex/rules/*.rules`: command permission rules for running commands outside the sandbox. Do not mix these with coding conventions in `.agents/rules.md`.
- `.codex/hooks.json` or `.codex/config.toml` hooks: lifecycle automation such as prompt checks, command validation, or final verification gates.
## Placement Rules
- Put stable team conventions in `AGENTS.md` only when every task must see them without opening another file.
- Put detailed explanations in `.agents/*.md` and link them from `AGENTS.md`.
- Put reusable task workflows in `.agents/skills/<skill-name>/SKILL.md`.
- Put mechanical enforcement in linters, tests, hooks, or `.codex/rules`; do not rely on prose when tooling can enforce the rule.
- Keep user-specific preferences out of the repository. They belong in user-level Codex configuration or user-level skills.
## Skill Authoring Rules
- Use lowercase hyphenated names.
- Start descriptions with `Use when...`.
- Describe trigger conditions in the description, not the workflow.
- Keep `SKILL.md` lean; link to `.agents/*.md` for large reference material.
- Add scripts only when deterministic behavior is needed repeatedly.
+232
View File
@@ -0,0 +1,232 @@
# Architecture
## Core Integration
The Go proxy core in `core/` operates in two modes.
Android lib mode:
- Go core is compiled as a C shared library, `libclash.so`, through `go build -buildmode=c-shared` with CGO.
- Flutter calls it via FFI through the `service` plugin.
- Dart-side implementation: `lib/core/lib.dart` (`CoreLib`).
Desktop core mode:
- Go core runs as a separate process with `CGO_ENABLED=0`.
- 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`.
Key Go core files:
- `core/hub.go`: handler functions.
- `core/method.go`: MethodChannel-style method-call dispatch and response envelopes.
- `core/lib.go`: CGO exports.
- `core/server.go`: socket server.
## State Management
Provider files in `lib/providers/`:
- `app.dart`: runtime/UI state, logs, traffic, delays, loading, navigation.
- `config.dart`: persistent config providers, app settings, theme, VPN, proxy style.
- `state.dart`: derived/computed providers, navigation, proxy, tray, color scheme.
- `action.dart`: business logic notifiers, setup, backup, core lifecycle, proxy selection.
- `database.dart`: Drift database provider wrappers.
`globalState` in `lib/state.dart` is a singleton holding app lifecycle, timers, theme, and start/stop state. Providers are generated into `lib/providers/generated/`.
## Database
The app uses Drift/SQLite in `lib/database/`. Current schema version is 2.
Tables:
- `Profiles`
- `Scripts`
- `Rules`
- `ProfileRuleLinks` (`profile_rule_mapping`)
- `ProxyGroups`
- `IconRecords` (`icon_records`)
Rule scenes distinguish global added rules, profile added rules, profile custom rules, and disabled links. Rule and proxy-group ordering use fractional indexing.
Generated Drift output lives in `lib/database/generated/database.g.dart`. After schema changes, run code generation and add or update focused database tests under `test/database/` when converter or migration behavior changes.
## Manager Stack
Managers are nested `InheritedWidget`/`StatefulWidget` components in `lib/application.dart`:
```text
AppEnvManager > StatusManager > ThemeManager
> [Desktop: WindowManager > TrayManager > HotKeyManager > ProxyManager]
> ConnectivityManager > CoreManager > AppStateManager
> [Mobile: AndroidManager > VpnManager | Desktop: WindowHeaderContainer]
```
Each manager in `lib/manager/` handles a specific platform concern. Desktop-only managers are conditionally inserted.
## Core Controller and Actions
`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.
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.
- `BackupAction`: backup/restore with WebDAV sync.
- `CoreAction`: core lifecycle, init, connect, restart, shutdown.
- `SystemAction`: system integration, tray, exit, brightness.
- `StoreAction`: profile storage operations.
- `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
Desktop:
- `WindowManager`
- `TrayManager`
- `HotKeyManager`
- `ProxyManager`
Mobile:
- `AndroidManager`
- `TileManager`
- `VpnManager`
Shared:
- `ConnectivityManager`
- `CoreManager`
- `AppStateManager`
- `StatusManager`
- `ThemeManager`
## Build System
`setup.dart` is the release build orchestrator:
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/`.
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`. CMake forwards the active configuration through `BUILDKIT_CONFIGURATION`.
- Android: Gradle include, `buildkit/gradle/plugin.gradle`, `build_tool android`.
### Setup Build Harness Plugin
`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.
Responsibilities are deliberately split:
- 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-time harness for Go core artifacts and the Windows Rust helper; no runtime Dart API.
- `proxy`: system proxy configuration.
- `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.
- `flutter_distributor`: app packaging/distribution.
## Rust Helper Service
`services/helper/` is a Windows-only privileged helper for starting the core as admin and managing TUN. It is built with:
```bash
make core-windows
```
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.
+117
View File
@@ -0,0 +1,117 @@
# Commands
## Building
Update submodules first. The ClashMeta Go core lives in `core/Clash.Meta/`.
```bash
git submodule update --init --recursive
```
Full package build, including Go core, Flutter, and packaging, runs through `setup.dart`:
```bash
dart setup.dart macos
dart setup.dart linux
dart setup.dart windows
dart setup.dart android
```
Build only the Go core and skip Flutter packaging:
```bash
make core-macos
make core-linux
make core-windows
make core-android
```
Pass `ARCH` or `TARGET_PLATFORM` through `make` when needed, for example:
```bash
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 follows FVM's `stable` channel locally. Release CI pins an exact
Flutter version separately; see `.agents/project.md`.
```bash
fvm flutter pub get
fvm flutter run
fvm flutter test
```
Plain Flutter also works when the global SDK matches project constraints:
```bash
flutter pub get
flutter run
flutter test
```
Use `flutter test`, not `dart test`, because models pull in Flutter types.
## Code Generation
Run code generation after modifying models, providers, or database schema:
```bash
dart run build_runner build --delete-conflicting-outputs
dart run build_runner watch
```
Code generation covers:
- Riverpod providers through `riverpod_generator`.
- Models through `freezed` and `json_serializable`.
- Database tables through `drift_dev`.
Generated output paths, configured in `build.yaml`:
- `lib/models/generated/*.g.dart`, `*.freezed.dart`.
- `lib/providers/generated/*.g.dart`.
- `lib/database/generated/*.g.dart`.
## Testing
Tests use `package:test/test.dart` for pure Dart logic and `flutter_test` for provider and widget tests. `mocktail` is the mocking framework.
```bash
flutter test test/models/
flutter test test/core/
flutter test test/providers/
flutter test test/common/
flutter test test/database/
flutter test test/widgets/
flutter test test/setup_test.dart
flutter test plugins/proxy/test/proxy_test.dart
```
Root `flutter test` only discovers the root package's `test/` directory by default. Include bundled plugin Dart tests by passing paths explicitly, or run `flutter test` from that plugin package directory. Native plugin tests under platform folders are not run by `flutter test`.
## Verify
The pull-request and release workflows run these root-package checks in order:
```bash
flutter pub get
flutter analyze --no-fatal-infos
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.
+30
View File
@@ -0,0 +1,30 @@
# Project Context
FlClash is a multi-platform proxy client based on ClashMeta (mihomo), built with Flutter. It supports Android, Windows, macOS, and Linux, using a Material You design with Surfboard-like UI.
## Version Notes
- `.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
Linux:
```bash
sudo apt-get install libayatana-appindicator3-dev libkeybinder-3.0-dev
```
Windows:
- GCC and Inno Setup.
- `ANDROID_NDK` env var for Android builds.
macOS:
```bash
npm install -g appdmg
```
+57
View File
@@ -0,0 +1,57 @@
# Rules
These are repository coding and testing conventions. Codex command permission rules belong in `.codex/rules/*.rules`; see `.agents/agent-config.md` before adding those.
## Dart and Flutter Style
`analysis_options.yaml` enforces these non-default rules:
- `prefer_single_quotes: true`: always use single quotes.
- `require_trailing_commas: true`: use trailing commas in multi-line argument lists.
- `sort_child_properties_last: true`: `child:` must be the last named parameter.
- `avoid_print: true`: do not use `print()` calls.
- `prefer_const_constructors: true` and `prefer_const_declarations: true`.
- `prefer_final_locals: true` and `prefer_final_in_for_each: true`.
- `always_declare_return_types: true`.
Generated directories are excluded from analysis:
- `build/**`
- `lib/l10n/intl/**`
- `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.
Use `ProviderContainer` directly for simple Riverpod provider tests. The generated Riverpod `update()` method takes a callback:
```dart
notifier.update((state) => newValue);
```
When testing freezed models with nested objects, always round-trip through `jsonEncode` and `jsonDecode`. Direct `fromJson(toJson())` fails for nested freezed types because `toJson()` stores child objects directly instead of maps.
## Generated Code
Do not manually edit generated files under:
- `lib/l10n/l10n.dart`
- `lib/models/generated/`
- `lib/providers/generated/`
- `lib/database/generated/`
- `lib/l10n/intl/`
After schema, model, or provider changes, run build generation and include focused tests when behavior changes.
+17
View File
@@ -0,0 +1,17 @@
# Agent Skills
Repo-scoped Codex skills live under `.agents/skills/*/SKILL.md`. Codex can discover these by skill `name` and `description`, then load the full instructions only when a task matches.
## Available Repo Skills
- `localization`: hardcoded UI text scans, ARB updates, locale generation, and localization verification.
- `provider-tests`: Riverpod provider, notifier, and state-management tests.
- `ui-work`: Flutter UI, widgets, Material You styling, navigation surfaces, and user-facing interactions.
- `core-platform`: core integration, platform managers, Go core communication, desktop/mobile behavior, and Windows helper flow.
## Authoring Notes
- Add new repeatable workflows as `.agents/skills/<skill-name>/SKILL.md`.
- Keep skill descriptions trigger-focused and start them with `Use when...`.
- Keep long reference material in `.agents/*.md`; skills should link to it instead of duplicating it.
- Put command permission rules in `.codex/rules/*.rules`, not in this file.
+36
View File
@@ -0,0 +1,36 @@
---
name: core-platform
description: Use when changing FlClash core integration, platform managers, Go core communication, desktop/mobile platform behavior, or Windows helper flow.
---
# Core And Platform
## When To Use
Use this for changes touching `lib/core/`, `lib/manager/`, `core/`, `services/helper/`, build hooks, system proxy, tray, VPN, TUN, or platform-specific desktop/mobile behavior.
## Workflow
1. Identify which boundary owns the behavior:
- Android lib mode: `lib/core/lib.dart`.
- Desktop process/socket mode: `lib/core/service.dart`.
- Shared facade: `lib/core/controller.dart` and `lib/core/interface.dart`.
- Platform lifecycle: `lib/manager/`.
2. Route feature code through `CoreController` and `CoreHandlerInterface`; avoid direct calls to platform implementations outside their boundary.
3. Keep desktop and mobile paths explicit.
4. For action-layer behavior, inspect `lib/providers/action.dart` and relevant generated providers.
5. Add or update shared Dart tests for logic that can be isolated.
6. Manually verify native behavior when automated coverage is not practical.
## Reference Files
Read `.agents/architecture.md` for the current core modes, manager stack, build hooks, local plugins, and Windows helper notes.
## Pitfalls
- 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.
+45
View File
@@ -0,0 +1,45 @@
---
name: localization
description: Use when changing FlClash UI text, scanning for hardcoded Chinese strings, updating ARB localization, or fixing generated locale output in this repository.
---
# Localization
## When To Use
Use this for localization work inside `/Users/Shared/follow/FlClash`, especially hardcoded UI text in `lib/`, ARB updates, missing translations, or generated `lib/l10n/` output that does not match source ARB values.
Do not use this for README translation sync or manual edits to generated localization Dart.
## Workflow
1. Confirm `pubspec.yaml` still uses `flutter_intl`, source ARBs under `arb/`, and generated output under `lib/l10n/`.
2. Scan user-facing Dart text before opening many files:
```bash
rg -n "[\\p{Han}]" lib -g '!lib/l10n/intl/**' -g '!lib/**/generated/**'
```
3. Inspect the smallest relevant call sites and nearby ARB keys.
4. Add or update every source ARB:
- `arb/intl_en.arb`
- `arb/intl_zh_CN.arb`
- `arb/intl_ja.arb`
- `arb/intl_ru.arb`
5. Replace inline strings with existing project accessors:
- Widgets with `BuildContext`: `context.appLocalizations.key` from `common.dart`.
- Controllers/providers/non-widget code: `currentAppLocalizations.key` from `app_localizations.dart`.
6. Regenerate:
```bash
dart run intl_utils:generate
```
7. Verify changed Dart files with `flutter analyze` when practical.
8. Re-run the Han-text scan for targeted strings.
## Pitfalls
- If ja/ru still show English, fix ja/ru source ARB values and regenerate. Do not edit generated Dart.
- Ignore `lib/l10n/intl/**` and `lib/**/generated/**` during text scans.
- If generator or analyzer hits local cache permission friction, rerun serially before treating it as a code issue.
+37
View File
@@ -0,0 +1,37 @@
---
name: provider-tests
description: Use when adding or updating FlClash Riverpod provider tests, notifier tests, or state-management tests in this repository.
---
# Provider Tests
## When To Use
Use this for tests under `test/providers/` or any change that validates Riverpod providers, generated notifiers, app state defaults, or provider interactions.
For broader test expansion, pair this with `.agents/rules.md` and `.agents/commands.md`.
## Workflow
1. Read the provider under test and its generated public API before writing assertions.
2. Use `ProviderContainer` directly when no widget tree is needed.
3. Dispose containers in teardown or with `addTearDown(container.dispose)`.
4. Prefer generated notifier APIs over implementation details. Generated `update()` takes a callback:
```dart
notifier.update((state) => newValue);
```
5. Mock external dependencies with `mocktail`; register fallback values for freezed params used with `any()`.
6. Keep tests focused on behavior: defaults, state transitions, persistence boundaries, and side effects.
7. Run the narrowest relevant test first:
```bash
flutter test test/providers/
```
## Pitfalls
- Do not use `dart test`; FlClash models and provider tests may depend on Flutter types.
- Re-check source defaults before asserting them; provider defaults can drift.
- If async provider timing matters, wait on provider futures or state changes instead of fixed sleeps.
+32
View File
@@ -0,0 +1,32 @@
---
name: ui-work
description: Use when changing FlClash Flutter UI, widgets, screens, Material You styling, navigation surfaces, or user-facing interactions.
---
# UI Work
## When To Use
Use this for user-facing Flutter UI changes in `lib/`, including widgets, screens, navigation surfaces, settings rows, dialogs, and interaction behavior.
## Workflow
1. Locate existing nearby widgets and reuse their patterns before adding new abstractions.
2. Follow current Material You and Surfboard-like visual conventions.
3. Use existing providers, notifiers, and helpers where possible.
4. Keep `child:` last in widget constructors.
5. Prefer `const` constructors and final locals.
6. Localize user-facing text through ARB; use `localization` when text changes are non-trivial.
7. Add focused widget tests when behavior changes, especially for rendering states, taps, scrolling, and empty/error states.
8. Run targeted verification:
```bash
flutter analyze
flutter test test/widgets/
```
## Pitfalls
- Do not introduce a new visual system for one screen.
- Do not manually edit generated localization or provider files.
- Avoid broad layout rewrites unless the requested change requires them.
+5
View File
@@ -0,0 +1,5 @@
# CodeGraph data files — local to each machine, not for committing.
# Ignore everything in .codegraph/ except this file itself, so transient
# files (the database, daemon.pid, sockets, logs) never show up in git.
*
!.gitignore
+12
View File
@@ -0,0 +1,12 @@
{
"mcpServers": {
"codegraph": {
"type": "stdio",
"command": "codegraph",
"args": [
"serve",
"--mcp"
]
}
}
}
+57
View File
@@ -0,0 +1,57 @@
name: 问题反馈 / Bug report
title: "[BUG] "
description: 反馈你遇到的问题 / Report the issue you are experiencing
body:
- type: markdown
attributes:
value: |
## 在提交问题之前,请确认以下事项:
1. 请务必给issue填写一个简洁明了的标题,以便他人快速检索
2. 请确保[已有的问题](https://github.com/chen08209/FlClash/issues?q=is%3Aissue) 中没有人提交过相似issue,否则请在已有的issue下进行讨论
3. 请务必按照模板规范详细描述问题,否则issue将会被直接关闭
## Before submitting the issue, please make sure of the following checklist:
1. Please be sure to fill in a concise and clear title for the issue so that others can quickly search
2. Please make sure there is no similar issue in the [existing issues](https://github.com/chen08209/FlClash/issues?q=is%3Aissue), otherwise please discuss under the existing issue
3. Please describe the problem in detail according to the template specification, otherwise issue will be closed directly.
- type: textarea
id: description
attributes:
label: 问题描述 / Describe the bug
description: 详细清晰地描述你遇到的问题,并配合截图 / Describe the problem you encountered in detail and clearly, and provide screenshots
validations:
required: true
- type: textarea
attributes:
label: 软件版本 / Version
description: 请提供FlClash的具体版本 / Please provide the specific version of FlClash.
validations:
required: true
- type: textarea
attributes:
label: 复现步骤 / To Reproduce
description: 请提供复现问题的步骤 / Steps to reproduce the behavior
validations:
required: true
- type: dropdown
attributes:
label: 操作系统 / OS
options:
- Android
- Windows
- MacOS
- Linux
validations:
required: true
- type: input
attributes:
label: 操作系统版本 / OS Version
description: 请提供你的操作系统版本,Linux请额外提供桌面环境及窗口系统 / Please provide your OS version, for Linux, please also provide the desktop environment and window system
validations:
required: true
- type: textarea
attributes:
label: 日志(勿上传日志文件,请粘贴日志内容) / Log (Do not upload the log file, paste the log content directly)
description: 请提供完整或相关部分的Debug日志(请在“软件左侧菜单”->“设置”->“日志等级”调整到debug / Please provide a complete or relevant Debug log (please adjust it to debug in the left menu of software-> Settings-> Log Level)
validations:
required: true
+4
View File
@@ -0,0 +1,4 @@
contact_links:
- name: 讨论交流 / Communication
url: https://t.me/+G-veVtwBOl4wODc1
about: 在 Telegram 群组中与其他用户讨论交流 / Communicate with other users in the Telegram group
@@ -0,0 +1,42 @@
name: 功能请求 / Feature request
title: "[Feature] "
description: 提出你的功能请求 / Propose your feature request
body:
- type: markdown
attributes:
value: |
## 在提交问题之前,请确认以下事项:
1. 请务必给issue填写一个简洁明了的标题,以便他人快速检索
2. 请确保[已有的问题](https://github.com/chen08209/FlClash/issues?q=is%3Aissue) 中没有人提交过相似issue,否则请在已有的issue下进行讨论
3. 请务必按照模板规范详细描述问题,否则issue将会被直接关闭
## Before submitting the issue, please make sure of the following checklist:
1. Please be sure to fill in a concise and clear title for the issue so that others can quickly search
2. Please make sure there is no similar issue in the [existing issues](https://github.com/chen08209/FlClash/issues?q=is%3Aissue), otherwise please discuss under the existing issue
3. Please describe the problem in detail according to the template specification, otherwise issue will be closed directly.
- type: textarea
id: description
attributes:
label: 功能描述 / Feature description
description: 详细清晰地描述你的功能请求 / A clear and concise description of what the feature is
validations:
required: true
- type: textarea
attributes:
label: 使用场景 / Use case
description: 请描述你的功能请求的使用场景 / Please describe the use case of your feature request
validations:
required: true
- type: checkboxes
id: os-labels
attributes:
label: 适用系统 / Target OS
description: 请选择该功能适用的操作系统(至少选择一个) / Please select the operating system(s) for this feature request (select at least one)
options:
- label: Android
- label: Windows
- label: MacOS
- label: Linux
validations:
required: true
+39
View File
@@ -0,0 +1,39 @@
cask "flclash" do
version "VERSION"
on_macos do
arch arm: "arm64", intel: "amd64"
sha256 arm: "ARM_SHA256",
intel: "AMD_SHA256"
url "https://github.com/chen08209/FlClash/releases/download/v#{version}/FlClash-#{version}-macos-#{arch}.dmg"
end
name "FlClash"
desc "Multi-platform proxy client based on ClashMeta"
homepage "https://github.com/chen08209/FlClash"
livecheck do
url :url
strategy :github_latest
end
depends_on :macos
app "FlClash.app"
postflight do
system_command "xattr",
args: ["-rd", "com.apple.quarantine", "#{appdir}/FlClash.app"]
end
uninstall quit: "com.follow.clash"
zap trash: [
"~/Library/Application Support/com.follow.clash",
"~/Library/Caches/com.follow.clash",
"~/Library/Preferences/com.follow.clash.plist",
"~/Library/Saved Application State/com.follow.clash.savedState",
]
end
+1 -1
View File
@@ -42,7 +42,7 @@
<td>
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-linux-amd64.AppImage"><img src="https://img.shields.io/badge/AppImage-x64-f84e29.svg?logo=linux"> </a><br>
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-linux-amd64.deb"><img src="https://img.shields.io/badge/DebPackage-x64-FF9966.svg?logo=debian"> </a><br>
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-linux-amd64.deb"><img src="https://img.shields.io/badge/RpmPackage-x64-F1B42F.svg?logo=redhat"> </a>
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-linux-amd64.rpm"><img src="https://img.shields.io/badge/RpmPackage-x64-F1B42F.svg?logo=redhat"> </a>
</td>
</tr>
</tbody>
+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"
+216 -88
View File
@@ -4,11 +4,108 @@ on:
push:
tags:
- 'v*'
pull_request:
env:
IS_STABLE: ${{ !contains(github.ref, '-') }}
FLUTTER_VERSION: '3.44.4'
jobs:
test:
name: Test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
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:
channel: stable
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
- name: Install dependencies
run: flutter pub get
- name: Analyze
run: flutter analyze --no-fatal-infos
- 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:
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
needs: [ test, windows-helper-test ]
runs-on: ${{ matrix.os }}
strategy:
matrix:
@@ -16,67 +113,89 @@ jobs:
- platform: android
os: ubuntu-latest
- platform: windows
os: windows-latest
os: Windows-2022
arch: amd64
- platform: linux
os: ubuntu-latest
os: ubuntu-22.04
arch: amd64
- platform: macos
os: macos-13
os: macos-15-intel
arch: amd64
- platform: macos
os: macos-latest
arch: arm64
- platform: windows
os: windows-11-arm
arch: arm64
- platform: linux
os: ubuntu-24.04-arm
arch: arm64
steps:
- name: Setup rust
if: startsWith(matrix.os, 'windows-11-arm')
shell: pwsh
run: |
Invoke-WebRequest -Uri "https://win.rustup.rs/aarch64" -OutFile rustup-init.exe
.\rustup-init.exe -y --default-toolchain stable
$cargoPath = "$env:USERPROFILE\.cargo\bin"
Add-Content $env:GITHUB_PATH $cargoPath
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup JAVA
if: startsWith(matrix.platform,'android')
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: 17
- name: Setup NDK
if: startsWith(matrix.platform,'android')
uses: nttld/setup-ndk@v1
id: setup-ndk
with:
ndk-version: r26b
add-to-path: true
link-to-sdk: true
- name: Setup Android Signing
if: startsWith(matrix.platform,'android')
run: |
echo "${{ secrets.KEYSTORE }}" | base64 --decode > android/app/keystore.jks
echo "keyAlias=${{ secrets.KEY_ALIAS }}" >> android/local.properties
echo "storePassword=${{ secrets.STORE_PASSWORD }}" >> android/local.properties
echo "keyPassword=${{ secrets.KEY_PASSWORD }}" >> android/local.properties
echo "${{ secrets.SERVICE_JSON }}" | base64 --decode > android/app/google-services.json
{
echo "keyAlias=${{ secrets.KEY_ALIAS }}"
echo "storePassword=${{ secrets.STORE_PASSWORD }}"
echo "keyPassword=${{ secrets.KEY_PASSWORD }}"
} >> android/local.properties
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: 'stable'
go-version: '1.26.4'
cache-dependency-path: |
core/go.sum
- name: Enable git long paths (Windows)
if: startsWith(matrix.os, 'windows')
run: git config --global core.longpaths true
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
channel: master
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
- name: Setup Xcode
if: startsWith(matrix.platform,'macos')
run: |
sudo xcode-select -s /Applications/Xcode_26.3.app || sudo xcode-select -s /Applications/Xcode_26.2.app
xcodebuild -version
- name: Setup NDK
if: startsWith(matrix.platform,'android')
uses: nttld/setup-ndk@v1
with:
ndk-version: r28c
add-to-path: true
link-to-sdk: true
- name: Get Flutter Dependency
run: flutter pub get
run: |
flutter --version
flutter pub get
- name: Setup
run: dart setup.dart ${{ matrix.platform }} ${{ matrix.arch && format('--arch {0}', matrix.arch) }} ${{ env.IS_STABLE == 'true' && '--env stable' || '' }}
run: dart setup.dart ${{ matrix.platform }} ${{ env.IS_STABLE == 'true' && '--env stable' || '' }} -v
- name: Upload
uses: actions/upload-artifact@v4
@@ -86,49 +205,42 @@ jobs:
overwrite: true
changelog:
if: ${{ !contains(github.ref, '-') }}
permissions:
contents: write
runs-on: ubuntu-latest
needs: [ build ]
steps:
- name: Checkout
uses: actions/checkout@v4
if: ${{ env.IS_STABLE == 'true' }}
with:
fetch-depth: 0
ref: refs/heads/main
- name: Generate
if: ${{ env.IS_STABLE == 'true' }}
run: |
tags=($(git tag --merged $(git rev-parse HEAD) --sort=-creatordate))
preTag=$(grep -oP '^## \K.*' CHANGELOG.md | head -n 1)
currentTag=""
for ((i = 0; i <= ${#tags[@]}; i++)); do
if (( i < ${#tags[@]} )); then
tag=${tags[$i]}
else
tag=""
fi
if [ -n "$currentTag" ]; then
if [ "$(echo -e "$currentTag\n$preTag" | sort -V | head -n 1)" == "$currentTag" ]; then
break
fi
fi
if [ -n "$currentTag" ]; then
echo "## $currentTag" >> NEW_CHANGELOG.md
echo "" >> NEW_CHANGELOG.md
if [ -n "$tag" ]; then
git log --pretty=format:"%B" "$tag..$currentTag" | awk 'NF {print "- " $0} !NF {print ""}' >> NEW_CHANGELOG.md
else
git log --pretty=format:"%B" "$currentTag" | awk 'NF {print "- " $0} !NF {print ""}' >> NEW_CHANGELOG.md
fi
echo "" >> NEW_CHANGELOG.md
fi
currentTag=$tag
last_ver=$(grep -m1 '^## ' CHANGELOG.md 2>/dev/null | sed 's/^## //')
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"
done
cat CHANGELOG.md >> NEW_CHANGELOG.md
cat NEW_CHANGELOG.md > CHANGELOG.md
[ -f CHANGELOG.md ] && cat CHANGELOG.md >> "$temp"
mv "$temp" CHANGELOG.md
- name: Commit
if: ${{ env.IS_STABLE == 'true' }}
run: |
git add CHANGELOG.md
if ! git diff --cached --quiet; then
@@ -136,8 +248,7 @@ jobs:
git config --local user.email "chen08209@gmail.com"
git config --local user.name "chen08209"
git commit -m "Update changelog"
git push
if [ $? -eq 0 ]; then
if git push; then
echo "Push succeeded"
else
echo "Push failed"
@@ -149,7 +260,8 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
upload:
permissions: write-all
permissions:
contents: write
needs: [ build ]
runs-on: ubuntu-latest
services:
@@ -174,36 +286,18 @@ jobs:
merge-multiple: true
- name: Generate release.md
shell: bash
run: |
tags=($(git tag --merged $(git rev-parse HEAD) --sort=-creatordate))
preTag=$(curl --silent "https://api.github.com/repos/chen08209/FlClash/releases/latest" | grep -Po '"tag_name": "\K.*?(?=")' || echo "")
currentTag=""
for ((i = 0; i <= ${#tags[@]}; i++)); do
if (( i < ${#tags[@]} )); then
tag=${tags[$i]}
else
tag=""
fi
if [ -n "$currentTag" ]; then
if [ "$(echo -e "$currentTag\n$preTag" | sort -V | head -n 1)" == "$currentTag" ]; then
break
fi
fi
if [ -n "$currentTag" ]; then
if [ -n "$tag" ]; then
git log --pretty=format:"%B" "$tag..$currentTag" | awk 'NF {print "- " $0} !NF {print ""}' >> release.md
else
git log --pretty=format:"%B" "$currentTag" | awk 'NF {print "- " $0} !NF {print ""}' >> release.md
fi
echo "" >> release.md
fi
currentTag=$tag
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 }}
TAG: ${{ github.ref_name }}
RUN_ID: ${{ github.run_id }}
run: |
python -m pip install --upgrade pip
pip install requests
@@ -214,6 +308,12 @@ jobs:
version=$(echo "${{ github.ref_name }}" | sed 's/^v//')
sed "s|VERSION|$version|g" ./.github/release_template.md >> release.md
- name: Generate sha256
if: ${{ env.IS_STABLE == 'true' }}
run: |
cd ./dist
sha256sum ./* > SHA256SUMS
- name: Release
if: ${{ env.IS_STABLE == 'true' }}
uses: softprops/action-gh-release@v2
@@ -221,6 +321,35 @@ jobs:
files: ./dist/*
body_path: './release.md'
- name: Create Homebrew Cask Source Dir
if: ${{ env.IS_STABLE == 'true' }}
run: |
version=$(echo "${{ github.ref_name }}" | sed 's/^v//')
arm_sha=$(sha256sum "./dist/FlClash-${version}-macos-arm64.dmg" | awk '{print $1}')
amd_sha=$(sha256sum "./dist/FlClash-${version}-macos-amd64.dmg" | awk '{print $1}')
mkdir -p ./brew/Casks
sed \
-e "s|VERSION|$version|g" \
-e "s|ARM_SHA256|$arm_sha|g" \
-e "s|AMD_SHA256|$amd_sha|g" \
./.github/homebrew_cask_template.rb > ./brew/Casks/flclash.rb
- name: Push to Homebrew tap
if: ${{ env.IS_STABLE == 'true' }}
uses: cpina/github-action-push-to-another-repository@v1.7.2
env:
SSH_DEPLOY_KEY: ${{ secrets.HOMEBREW_SSH_DEPLOY_KEY }}
with:
source-directory: ./brew/
destination-github-username: chen08209
destination-repository-name: homebrew-tap
user-name: 'chen08029'
user-email: 'chen08209@gmail.com'
target-branch: main
commit-message: Update Homebrew cask from ${{ github.ref_name }}
target-directory: /
- name: Create Fdroid Source Dir
if: ${{ env.IS_STABLE == 'true' }}
run: |
@@ -237,9 +366,8 @@ jobs:
source-directory: ./tmp/
destination-github-username: chen08209
destination-repository-name: FlClash-fdroid-repo
user-name: 'github-actions[bot]'
user-email: 'github-actions[bot]@users.noreply.github.com'
user-name: 'chen08029'
user-email: 'chen08209@gmail.com'
target-branch: main
commit-message: Update from ${{ github.ref_name }}
target-directory: /tmp/
+29 -6
View File
@@ -21,7 +21,7 @@ migrate_working_dir/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
.vscode/
# Flutter/Dart/Pub related
**/doc/api/
@@ -41,15 +41,38 @@ app.*.symbols
# Obfuscation related
app.*.map.json
# Claude Code - ignore user-specific settings only
.claude/settings.local.json
# Documentation (generated/planning artifacts)
docs/
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
/android/**/.cxx
/android/**/build
/android/common/**/.**/
/android/common/local.*
/android/core/**/includes/
/android/core/**/cmake-build-*/
/android/core/**/jniLibs/
#libclash
#FlClash
/libclash/
#jniLibs
/android/app/src/main/jniLibs/
/services/helper/target
/macos/**/Package.resolved
/macos/build/
/env.json
/core_sha256.json
/core/*.exe
/plugins/rust_api/.claude/
devtools_options.yaml
# FVM Version Cache
.fvm/
.fvmrc
/coverage/lcov.info
-10
View File
@@ -2,13 +2,3 @@
path = core/Clash.Meta
url = git@github.com:chen08209/Clash.Meta.git
branch = FlClash
[submodule "plugins/flutter_distributor"]
path = plugins/flutter_distributor
url = git@github.com:chen08209/flutter_distributor.git
branch = FlClash
[submodule "plugins/tray_manager"]
path = plugins/tray_manager
url = git@github.com:chen08209/tray_manager.git
branch = main
+12
View File
@@ -0,0 +1,12 @@
{
"mcpServers": {
"codegraph": {
"type": "stdio",
"command": "codegraph",
"args": [
"serve",
"--mcp"
]
}
}
}
+7 -22
View File
@@ -1,11 +1,11 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled.
# This file should be version controlled and should not be manually edited.
version:
revision: 796c8ef79279f9c774545b3771238c3098dbefab
channel: stable
revision: "adc901062556672b4138e18a4dc62a4be8f4b3c2"
channel: "stable"
project_type: app
@@ -13,26 +13,11 @@ project_type: app
migration:
platforms:
- platform: root
create_revision: 796c8ef79279f9c774545b3771238c3098dbefab
base_revision: 796c8ef79279f9c774545b3771238c3098dbefab
- platform: android
create_revision: 796c8ef79279f9c774545b3771238c3098dbefab
base_revision: 796c8ef79279f9c774545b3771238c3098dbefab
- platform: ios
create_revision: 796c8ef79279f9c774545b3771238c3098dbefab
base_revision: 796c8ef79279f9c774545b3771238c3098dbefab
- platform: linux
create_revision: 796c8ef79279f9c774545b3771238c3098dbefab
base_revision: 796c8ef79279f9c774545b3771238c3098dbefab
- platform: macos
create_revision: 796c8ef79279f9c774545b3771238c3098dbefab
base_revision: 796c8ef79279f9c774545b3771238c3098dbefab
- platform: web
create_revision: 796c8ef79279f9c774545b3771238c3098dbefab
base_revision: 796c8ef79279f9c774545b3771238c3098dbefab
create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
- platform: windows
create_revision: 796c8ef79279f9c774545b3771238c3098dbefab
base_revision: 796c8ef79279f9c774545b3771238c3098dbefab
create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
# User provided section
+20
View File
@@ -0,0 +1,20 @@
repos:
- repo: local
hooks:
- id: dart-format
name: dart format
entry: dart format
language: system
files: \.dart$
- id: flutter-analyze
name: flutter analyze
entry: flutter analyze --no-fatal-infos
language: system
pass_filenames: false
files: \.dart$
- id: flutter-test
name: flutter test
entry: flutter test --reporter expanded
language: system
pass_filenames: false
files: \.dart$
+7
View File
@@ -0,0 +1,7 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="main.dart" type="FlutterRunConfigurationType" factoryName="Flutter">
<option name="additionalArgs" value="--dart-define-from-file env.json" />
<option name="filePath" value="$PROJECT_DIR$/lib/main.dart" />
<method v="2" />
</configuration>
</component>
+13
View File
@@ -0,0 +1,13 @@
{
"lsp": {
"gopls": {
"binary": {
"env": {
"CGO_ENABLED": "0",
// "GOOS": "android",
// "GOARCH": "arm64",
},
},
},
},
}
+35
View File
@@ -0,0 +1,35 @@
# AGENTS.md
This file is the entry point for AI coding agents working in this repository. Keep it small: detailed guidance lives under
`.agents/`, and discoverable repo skills live under `.agents/skills/*/SKILL.md`.
## Start Here
Read these files before making changes:
- [.agents/project.md](.agents/project.md): project overview, versions, and build dependencies.
- [.agents/commands.md](.agents/commands.md): build, development, code generation, and test commands.
- [.agents/rules.md](.agents/rules.md): lint, testing, generated-code, and workflow rules.
Read these only when the task touches their area:
- [.agents/architecture.md](.agents/architecture.md): core integration, providers, database, managers, build system, and
local plugins.
- [.agents/agent-config.md](.agents/agent-config.md): how to choose between `AGENTS.md`, `.agents`, skills, Codex config,
command rules, and hooks.
- [.agents/skills.md](.agents/skills.md): index of repo-scoped skills in `.agents/skills/`.
## Highest Priority Rules
- Use `flutter test`, not `dart test`, because models pull in Flutter types.
- Run code generation after modifying models, providers, or database schema.
- Do not manually edit generated files.
- Follow `analysis_options.yaml`, especially single quotes, trailing commas, `child:` last, no `print()`, const/final
preferences, and declared return types.
- For CI parity, verify with `flutter pub get`, `flutter analyze --no-fatal-infos`, and
`flutter test --reporter expanded` when practical.
## Repo Skills
Use repo skills from `.agents/skills/` when a task matches their descriptions. Current skills cover localization,
provider tests, UI work, and core/platform changes.
+178
View File
@@ -1,3 +1,181 @@
## v0.8.94
- Fix macos performance issue
- Support custom global-ua
- Update core
- Optimize some details
- Fix linux silent launching not working
## v0.8.93
- Support custom overwrite
- Support run on demand
- Optimize windows ipc
- Optimize windows arm64
- Optimize build
- Optimize some details
- Update core
## v0.8.92
- Add sqlite store
- Optimize android quick action
- Optimize backup and restore
- Optimize more details
## v0.8.91
- Fix windows some issues
- Optimize overwrite handle
- Optimize access control page
- Optimize some details
## v0.8.90
- Fix android tile service
- Support append system DNS
- Fix some issues
- Update changelog
## v0.8.89
- Fix some issues
- Optimize Windows service mode
- Update core
- Update changelog
## v0.8.88
- Add android separates the core process
- Support core status check and force restart
- Optimize proxies page and access page
- Update flutter and pub dependencies
- Update go version
- Optimize more details
- Update changelog
## v0.8.87
- Optimize desktop view
- Optimize logs, requests, connection pages
- Optimize windows tray auto hide
- Optimize some details
- Update core
- Update changelog
## v0.8.86
- Fix windows tun issues
- Optimize android get system dns
- Optimize more details
- Update changelog
## v0.8.85
- Support override script
- Support proxies search
- Support svg display
- Optimize config persistence
- Add some scenes auto close connections
- Update core
- Optimize more details
## v0.8.84
- Fix windows service verify issues
- Update changelog
## v0.8.83
- Add windows server mode start process verify
- Add linux deb dependencies
- Add backup recovery strategy select
- Support custom text scaling
- Optimize the display of different text scale
- Optimize windows setup experience
- Optimize startTun performance
- Optimize android tv experience
- Optimize default option
- Optimize computed text size
- Optimize hyperOS freeform window
- Add developer mode
- Update core
- Optimize more details
- Add issues template
- Update changelog
## v0.8.82
- Optimize android vpn performance
- Add custom primary color and color scheme
- Add linux nad windows arm release
- Optimize requests and logs page
- Fix map input page delete issues
- Update changelog
## v0.8.81
- Add rule override
+4
View File
@@ -0,0 +1,4 @@
# CLAUDE.md
This repository uses [AGENTS.md](AGENTS.md) as the canonical agent entry point.
Read that file first, then follow the `.agents/` references it routes to.
+35
View File
@@ -0,0 +1,35 @@
SHELL := /bin/bash
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
help:
@echo 'make core # build macOS core by default'
@echo 'make core PLATFORM=linux ARCH=amd64'
@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) $(FORCE_ARG)
core-macos:
$(MAKE) core PLATFORM=macos
core-linux:
$(MAKE) core PLATFORM=linux
core-windows:
$(MAKE) core PLATFORM=windows
core-android:
$(MAKE) core PLATFORM=android
+36 -24
View File
@@ -41,8 +41,8 @@ on Mobile:
⚠️ Make sure to install the following dependencies before using them
```bash
sudo apt-get install appindicator3-0.1 libappindicator3-dev
sudo apt-get install keybinder-3.0
sudo apt-get install libayatana-appindicator3-dev
sudo apt-get install libkeybinder-3.0-dev
```
### Android
@@ -54,13 +54,20 @@ Support the following actions
com.follow.clash.action.STOP
com.follow.clash.action.CHANGE
com.follow.clash.action.TOGGLE
```
## Download
<a href="https://chen08209.github.io/FlClash-fdroid-repo/repo?fingerprint=789D6D32668712EF7672F9E58DEEB15FBD6DCEEC5AE7A4371EA72F2AAE8A12FD"><img alt="Get it on F-Droid" src="snapshots/get-it-on-fdroid.svg" width="200px"/></a> <a href="https://github.com/chen08209/FlClash/releases"><img alt="Get it on GitHub" src="snapshots/get-it-on-github.svg" width="200px"/></a>
### Homebrew
```bash
brew tap chen08209/tap
brew install --cask flclash
```
## Build
1. Update submodules
@@ -74,46 +81,51 @@ Support the following actions
- android
1. Install `Android SDK` , `Android NDK`
1. Install `Android SDK`, `Android NDK`
2. Set `ANDROID_NDK` environment variables
3. Run Build script
```bash
dart .\setup.dart android
```
- windows
1. You need a windows client
2. Install `Gcc`,`Inno Setup`
2. Set `ANDROID_NDK` environment variable
3. Run build script
```bash
dart .\setup.dart windows --arch <arm64 | amd64>
dart setup.dart android
```
- windows
1. Requires a Windows client
2. Install `GCC`, `Inno Setup`
3. Run build script
```bash
dart setup.dart windows
```
- linux
1. You need a linux client
1. Requires a Linux client
2. Run build script
2. Dependencies are auto-installed by setup script, or manually:
```bash
sudo apt-get install -y libayatana-appindicator3-dev libkeybinder-3.0-dev
```
3. Run build script
```bash
dart .\setup.dart linux --arch <arm64 | amd64>
dart setup.dart linux
```
- macOS
1. You need a macOS client
1. Requires a macOS client
2. Run build script
```bash
dart .\setup.dart macos --arch <arm64 | amd64>
dart setup.dart macos
```
## Star
@@ -124,4 +136,4 @@ The easiest way to support developers is to click on the star (⭐) at the top o
<a href="https://api.star-history.com/svg?repos=chen08209/FlClash&Date">
<img alt="start" width=50% src="https://api.star-history.com/svg?repos=chen08209/FlClash&Date"/>
</a>
</p>
</p>
+23 -11
View File
@@ -41,8 +41,8 @@ on Mobile:
⚠️ 使用前请确保安装以下依赖
```bash
sudo apt-get install appindicator3-0.1 libappindicator3-dev
sudo apt-get install keybinder-3.0
sudo apt-get install libayatana-appindicator3-dev
sudo apt-get install libkeybinder-3.0-dev
```
### Android
@@ -54,13 +54,20 @@ on Mobile:
com.follow.clash.action.STOP
com.follow.clash.action.CHANGE
com.follow.clash.action.TOGGLE
```
## Download
<a href="https://chen08209.github.io/FlClash-fdroid-repo/repo?fingerprint=789D6D32668712EF7672F9E58DEEB15FBD6DCEEC5AE7A4371EA72F2AAE8A12FD"><img alt="Get it on F-Droid" src="snapshots/get-it-on-fdroid.svg" width="200px"/></a> <a href="https://github.com/chen08209/FlClash/releases"><img alt="Get it on GitHub" src="snapshots/get-it-on-github.svg" width="200px"/></a>
### Homebrew
```bash
brew tap chen08209/tap
brew install --cask flclash
```
## Build
1. 更新 submodules
@@ -81,29 +88,34 @@ on Mobile:
3. 运行构建脚本
```bash
dart .\setup.dart android
dart setup.dart android
```
- windows
1. 你需要一个windows客户端
2. 安装 `Gcc`,`Inno Setup`
2. 安装 `GCC`,`Inno Setup`
3. 运行构建脚本
```bash
dart .\setup.dart windows --arch <arm64 | amd64>
dart setup.dart windows
```
- linux
1. 你需要一个linux客户端
2. 运行构建脚本
2. 依赖会由 setup 脚本自动安装,也可以手动安装:
```bash
sudo apt-get install -y libayatana-appindicator3-dev libkeybinder-3.0-dev
```
3. 运行构建脚本
```bash
dart .\setup.dart linux --arch <arm64 | amd64>
dart setup.dart linux
```
- macOS
@@ -113,10 +125,10 @@ on Mobile:
2. 运行构建脚本
```bash
dart .\setup.dart macos --arch <arm64 | amd64>
dart setup.dart macos
```
## Star History
## Star
支持开发者的最简单方式是点击页面顶部的星标(⭐)。
@@ -124,4 +136,4 @@ on Mobile:
<a href="https://api.star-history.com/svg?repos=chen08209/FlClash&Date">
<img alt="start" width=50% src="https://api.star-history.com/svg?repos=chen08209/FlClash&Date"/>
</a>
</p>
</p>
+42
View File
@@ -1 +1,43 @@
include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- build/**
- lib/l10n/intl/**
- lib/**/generated/**
- plugins/**
errors:
invalid_annotation_target: ignore
linter:
rules:
# Style
prefer_single_quotes: true
require_trailing_commas: true
sort_child_properties_last: true
# Avoid unnecessary code
unnecessary_new: true
unnecessary_this: true
unnecessary_const: true
avoid_print: true
avoid_unnecessary_containers: true
sized_box_for_whitespace: true
# Prefer best practices
prefer_const_constructors: true
prefer_const_declarations: true
prefer_final_locals: true
prefer_final_in_for_each: true
prefer_is_empty: true
prefer_is_not_empty: true
prefer_interpolation_to_compose_strings: true
# Type safety
always_declare_return_types: true
annotate_overrides: true
use_key_in_widget_constructors: true
no_leading_underscores_for_local_identifiers: true
# Async
use_build_context_synchronously: true
unnecessary_await_in_return: true
-114
View File
@@ -1,114 +0,0 @@
import com.android.build.gradle.tasks.MergeSourceSetFolders
plugins {
id "com.android.application"
id "kotlin-android"
id "dev.flutter.flutter-gradle-plugin"
}
def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties')
if (localPropertiesFile.exists()) {
localPropertiesFile.withReader('UTF-8') { reader ->
localProperties.load(reader)
}
}
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
if (flutterVersionCode == null) {
flutterVersionCode = '1'
}
def flutterVersionName = localProperties.getProperty('flutter.versionName')
if (flutterVersionName == null) {
flutterVersionName = '1.0'
}
def defStoreFile = file("keystore.jks")
def defStorePassword = localProperties.getProperty('storePassword')
def defKeyAlias = localProperties.getProperty('keyAlias')
def defKeyPassword = localProperties.getProperty('keyPassword')
def isRelease = defStoreFile.exists() && defStorePassword != null && defKeyAlias != null && defKeyPassword != null
android {
namespace "com.follow.clash"
compileSdkVersion 35
ndkVersion "27.1.12297006"
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
}
signingConfigs {
if (isRelease) {
release {
storeFile defStoreFile
storePassword defStorePassword
keyAlias defKeyAlias
keyPassword defKeyPassword
}
}
}
defaultConfig {
applicationId "com.follow.clash"
minSdkVersion 21
targetSdkVersion 35
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
}
buildTypes {
debug {
minifyEnabled false
applicationIdSuffix '.debug'
}
release {
if (isRelease) {
signingConfig signingConfigs.release
} else {
signingConfig signingConfigs.debug
}
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
}
}
}
tasks.register('copyNativeLibs', Copy) {
delete('src/main/jniLibs')
from('../../libclash/android')
into('src/main/jniLibs')
}
tasks.withType(MergeSourceSetFolders).configureEach {
dependsOn copyNativeLibs
}
flutter {
source '../..'
}
dependencies {
implementation 'androidx.core:core-splashscreen:1.0.1'
implementation 'com.google.code.gson:gson:2.10'
implementation("com.android.tools.smali:smali-dexlib2:3.0.7") {
exclude group: "com.google.guava", module: "guava"
}
}
afterEvaluate {
assembleDebug.dependsOn copyNativeLibs
assembleRelease.dependsOn copyNativeLibs
}
+108
View File
@@ -0,0 +1,108 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import java.util.Properties
plugins {
id("com.android.application")
id("dev.flutter.flutter-gradle-plugin")
id("com.google.gms.google-services")
id("com.google.firebase.crashlytics")
}
val localProperties = Properties().apply {
val localPropertiesFile = rootProject.file("local.properties")
if (localPropertiesFile.exists()) {
localPropertiesFile.inputStream().use { load(it) }
}
}
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
}
defaultConfig {
applicationId = "com.follow.clash"
minSdk = flutter.minSdkVersion
targetSdk = libs.versions.targetSdk.get().toInt()
versionCode = flutter.versionCode
versionName = flutter.versionName
}
signingConfigs {
if (hasReleaseSigning) {
create("release") {
storeFile = releaseStoreFile
storePassword = releaseStorePassword
keyAlias = releaseKeyAlias
keyPassword = releaseKeyPassword
}
}
}
packaging {
jniLibs {
useLegacyPackaging = true
}
}
buildTypes {
debug {
isMinifyEnabled = false
applicationIdSuffix = ".dev"
}
release {
isMinifyEnabled = true
isShrinkResources = true
if (hasReleaseSigning) {
signingConfig = signingConfigs.getByName("release")
} else {
signingConfig = signingConfigs.getByName("debug")
applicationIdSuffix = ".dev"
}
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}
flutter {
source = "../.."
}
dependencies {
implementation(project(":service"))
implementation(project(":common"))
implementation(project(":core"))
implementation(libs.core.splashscreen)
implementation(libs.gson)
implementation(libs.smali.dexlib2) {
exclude(group = "com.google.guava", module = "guava")
}
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.crashlytics.ndk)
implementation(libs.firebase.analytics)
}
+65
View File
@@ -0,0 +1,65 @@
{
"project_info": {
"project_number": "000000000000",
"project_id": "dev"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:000000000000:android:0000000000000000",
"android_client_info": {
"package_name": "com.follow.clash"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "0"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:000000000000:android:0000000000000000",
"android_client_info": {
"package_name": "com.follow.clash.debug"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "0"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:000000000000:android:0000000000000000",
"android_client_info": {
"package_name": "com.follow.clash.dev"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "0"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
]
}
+3 -1
View File
@@ -1,2 +1,4 @@
-keep class com.follow.clash.models.**{ *; }
-keep class com.follow.clash.models.** { *; }
-keep class com.follow.clash.service.models.** { *; }
+9 -10
View File
@@ -1,13 +1,12 @@
<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:label="FlClash Debug" tools:replace="android:label">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application
android:icon="@mipmap/ic_launcher"
android:label="FlClash Debug"
tools:replace="android:label">
<service
android:name=".services.FlClashTileService"
android:label="FlClash Debug"
tools:replace="android:label">
</service>
android:name=".TileService"
android:label="FlClash Debug"
tools:replace="android:label" />
</application>
</manifest>
+31 -62
View File
@@ -1,3 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
@@ -7,43 +8,48 @@
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<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.POST_NOTIFICATIONS" />
<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" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission
android:name="android.permission.QUERY_ALL_PACKAGES"
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.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<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="com.follow.clash.MainActivity"
android:name=".MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:exported="true"
android:hardwareAccelerated="true"
android:launchMode="singleTop"
android:theme="@style/LaunchTheme"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
@@ -62,12 +68,9 @@
</intent-filter>
</activity>
<meta-data
android:name="io.flutter.embedding.android.EnableImpeller"
android:value="false" />
<activity
android:name=".TempActivity"
android:name=".QuickActionActivity"
android:excludeFromRecents="true"
android:exported="true"
android:theme="@style/TransparentTheme">
<intent-filter>
@@ -80,17 +83,16 @@
</intent-filter>
<intent-filter>
<category android:name="android.intent.category.DEFAULT" />
<action android:name="${applicationId}.action.CHANGE" />
<action android:name="${applicationId}.action.TOGGLE" />
</intent-filter>
</activity>
<service
android:name=".services.FlClashTileService"
android:name=".TileService"
android:exported="true"
android:icon="@drawable/ic_stat_name"
android:label="FlClash"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
tools:targetApi="n">
android:icon="@drawable/ic"
android:label="@string/app_name"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
@@ -99,52 +101,19 @@
android:value="true" />
</service>
<provider
android:name=".FilesProvider"
android:authorities="${applicationId}.files"
<receiver
android:name=".ServiceBroadcastReceiver"
android:enabled="true"
android:exported="true"
android:grantUriPermissions="true"
android:permission="android.permission.MANAGE_DOCUMENTS"
android:process=":background">
android:permission="${applicationId}.permission.RECEIVE_BROADCASTS">
<intent-filter>
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
<action android:name="${applicationId}.intent.action.SERVICE_CREATED" />
<action android:name="${applicationId}.intent.action.SERVICE_DESTROYED" />
</intent-filter>
</provider>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileProvider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<service
android:name=".services.FlClashVpnService"
android:exported="false"
android:foregroundServiceType="dataSync"
android:permission="android.permission.BIND_VPN_SERVICE">
<intent-filter>
<action android:name="android.net.VpnService" />
</intent-filter>
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="vpn" />
</service>
<service
android:name=".services.FlClashService"
android:exported="false"
android:foregroundServiceType="dataSync">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="service" />
</service>
</receiver>
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
</manifest>
</manifest>
@@ -0,0 +1,105 @@
package com.follow.clash
import android.app.Application
import android.content.Context.MODE_PRIVATE
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.drawable.Drawable
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.widget.Toast
import androidx.core.graphics.drawable.toBitmap
import com.follow.clash.common.GlobalState
import com.follow.clash.models.SharedState
import com.google.gson.Gson
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.withContext
import java.io.File
import java.io.FileOutputStream
import java.util.concurrent.TimeUnit
private const val ICON_TTL_DAYS = 1L
val Application.sharedState: 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 =
withContext(Dispatchers.IO) {
val cacheDir = GlobalState.application.cacheDir
val iconDir = File(cacheDir, "icons").apply { mkdirs() }
return@withContext try {
val lastUpdateTime = getPackageInfo(packageName, 0).lastUpdateTime
val iconFile = File(iconDir, "${packageName}_${lastUpdateTime}.webp")
if (iconFile.exists() && !isExpired(iconFile)) {
return@withContext iconFile.absolutePath
}
iconDir.listFiles { f -> f.name.startsWith("${packageName}_") }?.forEach(File::delete)
val icon = getApplicationIcon(packageName)
saveDrawableToFile(icon, iconFile)
iconFile.absolutePath
} catch (_: Exception) {
val defaultIconFile = File(iconDir, "default_icon.webp")
if (!defaultIconFile.exists()) {
saveDrawableToFile(defaultActivityIcon, defaultIconFile)
}
defaultIconFile.absolutePath
}
}
private suspend fun saveDrawableToFile(drawable: Drawable, file: File) {
val bitmap = withContext(Dispatchers.Default) {
drawable.toBitmap(width = 128, height = 128)
}
try {
@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)
}
} finally {
if (!bitmap.isRecycled) bitmap.recycle()
}
}
private fun isExpired(file: File): Boolean {
val now = System.currentTimeMillis()
val age = now - file.lastModified()
return age > TimeUnit.DAYS.toMillis(ICON_TTL_DAYS)
}
inline fun <reified T : FlutterPlugin> FlutterEngine.plugin(): T? {
return plugins.get(T::class.java) as T?
}
fun MethodChannel.invokeMethodOnMainThread(method: String, arguments: Any? = null) {
Handler(Looper.getMainLooper()).post {
invokeMethod(method, arguments)
}
}
@@ -1,112 +0,0 @@
package com.follow.clash
import android.database.Cursor
import android.database.MatrixCursor
import android.os.CancellationSignal
import android.os.ParcelFileDescriptor
import android.provider.DocumentsContract.Document
import android.provider.DocumentsContract.Root
import android.provider.DocumentsProvider
import java.io.File
import java.io.FileNotFoundException
class FilesProvider : DocumentsProvider() {
companion object {
private const val DEFAULT_ROOT_ID = "0"
private val DEFAULT_DOCUMENT_COLUMNS = arrayOf(
Document.COLUMN_DOCUMENT_ID,
Document.COLUMN_DISPLAY_NAME,
Document.COLUMN_MIME_TYPE,
Document.COLUMN_FLAGS,
Document.COLUMN_SIZE,
)
private val DEFAULT_ROOT_COLUMNS = arrayOf(
Root.COLUMN_ROOT_ID,
Root.COLUMN_FLAGS,
Root.COLUMN_ICON,
Root.COLUMN_TITLE,
Root.COLUMN_SUMMARY,
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(Root.COLUMN_ROOT_ID, DEFAULT_ROOT_ID)
add(Root.COLUMN_FLAGS, Root.FLAG_LOCAL_ONLY)
add(Root.COLUMN_ICON, R.mipmap.ic_launcher)
add(Root.COLUMN_TITLE, context!!.getString(R.string.fl_clash))
add(Root.COLUMN_SUMMARY, "Data")
add(Root.COLUMN_DOCUMENT_ID, "/")
}
}
}
override fun queryChildDocuments(
parentDocumentId: String,
projection: Array<String>?,
sortOrder: String?
): Cursor {
val result = MatrixCursor(resolveDocumentProjection(projection))
val parentFile = if (parentDocumentId == "/") {
context?.filesDir
} else {
File(parentDocumentId)
} ?: throw FileNotFoundException("Parent directory not found")
parentFile.listFiles()?.forEach { file ->
includeFile(result, file)
}
return result
}
override fun queryDocument(documentId: String, projection: Array<String>?): Cursor {
val result = MatrixCursor(resolveDocumentProjection(projection))
val file = File(documentId)
includeFile(result, file)
return result
}
override fun openDocument(
documentId: String,
mode: String,
signal: CancellationSignal?
): ParcelFileDescriptor {
val file = File(documentId)
val accessMode = ParcelFileDescriptor.parseMode(mode)
return ParcelFileDescriptor.open(file, accessMode)
}
private fun includeFile(result: MatrixCursor, file: File) {
result.newRow().apply {
add(Document.COLUMN_DOCUMENT_ID, file.absolutePath)
add(Document.COLUMN_DISPLAY_NAME, file.name)
add(Document.COLUMN_SIZE, file.length())
add(
Document.COLUMN_FLAGS,
Document.FLAG_SUPPORTS_WRITE or Document.FLAG_SUPPORTS_DELETE
)
add(Document.COLUMN_MIME_TYPE, getDocumentType(file))
}
}
private fun getDocumentType(file: File): String {
return if (file.isDirectory) {
Document.MIME_TYPE_DIR
} else {
"application/octet-stream"
}
}
private fun resolveDocumentProjection(projection: Array<String>?): Array<String> {
return projection ?: DEFAULT_DOCUMENT_COLUMNS
}
}
@@ -1,18 +1,12 @@
package com.follow.clash;
package com.follow.clash
import android.app.Application
import android.content.Context
import com.follow.clash.common.GlobalState
class FlClashApplication : Application() {
companion object {
private lateinit var instance: FlClashApplication
fun getAppContext(): Context {
return instance.applicationContext
}
}
override fun onCreate() {
super.onCreate()
instance = this
override fun attachBaseContext(base: Context?) {
super.attachBaseContext(base)
GlobalState.init(this)
}
}
@@ -1,108 +0,0 @@
package com.follow.clash
import androidx.lifecycle.MutableLiveData
import com.follow.clash.plugins.AppPlugin
import com.follow.clash.plugins.TilePlugin
import com.follow.clash.plugins.VpnPlugin
import io.flutter.FlutterInjector
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.dart.DartExecutor
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
enum class RunState {
START,
PENDING,
STOP
}
object GlobalState {
val runLock = ReentrantLock()
val runState: MutableLiveData<RunState> = MutableLiveData<RunState>(RunState.STOP)
var flutterEngine: FlutterEngine? = null
private var serviceEngine: FlutterEngine? = null
fun getCurrentAppPlugin(): AppPlugin? {
val currentEngine = if (flutterEngine != null) flutterEngine else serviceEngine
return currentEngine?.plugins?.get(AppPlugin::class.java) as AppPlugin?
}
suspend fun getText(text: String): String {
return getCurrentAppPlugin()?.getText(text) ?: ""
}
fun getCurrentTilePlugin(): TilePlugin? {
val currentEngine = if (flutterEngine != null) flutterEngine else serviceEngine
return currentEngine?.plugins?.get(TilePlugin::class.java) as TilePlugin?
}
fun getCurrentVPNPlugin(): VpnPlugin? {
return serviceEngine?.plugins?.get(VpnPlugin::class.java) as VpnPlugin?
}
fun handleToggle() {
val starting = handleStart()
if (!starting) {
handleStop()
}
}
fun handleStart(): Boolean {
if (runState.value == RunState.STOP) {
runState.value = RunState.PENDING
runLock.lock()
val tilePlugin = getCurrentTilePlugin()
if (tilePlugin != null) {
tilePlugin.handleStart()
} else {
initServiceEngine()
}
return true
}
return false
}
fun handleStop() {
if (runState.value == RunState.START) {
runState.value = RunState.PENDING
runLock.lock()
getCurrentTilePlugin()?.handleStop()
}
}
fun handleTryDestroy() {
if (flutterEngine == null) {
destroyServiceEngine()
}
}
fun destroyServiceEngine() {
runLock.withLock {
serviceEngine?.destroy()
serviceEngine = null
}
}
fun initServiceEngine() {
if (serviceEngine != null) return
destroyServiceEngine()
runLock.withLock {
serviceEngine = FlutterEngine(FlClashApplication.getAppContext())
serviceEngine?.plugins?.add(VpnPlugin)
serviceEngine?.plugins?.add(AppPlugin())
serviceEngine?.plugins?.add(TilePlugin())
val vpnService = DartExecutor.DartEntrypoint(
FlutterInjector.instance().flutterLoader().findAppBundlePath(),
"_service"
)
serviceEngine?.dartExecutor?.executeDartEntrypoint(
vpnService,
if (flutterEngine == null) listOf("quick") else null
)
}
}
}
@@ -10,13 +10,13 @@ class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
flutterEngine.plugins.add(AppPlugin())
flutterEngine.plugins.add(ServicePlugin)
flutterEngine.plugins.add(ServicePlugin())
flutterEngine.plugins.add(TilePlugin())
GlobalState.flutterEngine = flutterEngine
ServiceState.attachFlutterEngine(flutterEngine)
}
override fun onDestroy() {
GlobalState.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"
}
}
@@ -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,25 +0,0 @@
package com.follow.clash
import android.app.Activity
import android.os.Bundle
import com.follow.clash.extensions.wrapAction
class TempActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
when (intent.action) {
wrapAction("START") -> {
GlobalState.handleStart()
}
wrapAction("STOP") -> {
GlobalState.handleStop()
}
wrapAction("CHANGE") -> {
GlobalState.handleToggle()
}
}
finishAndRemoveTask()
}
}
@@ -0,0 +1,61 @@
package com.follow.clash
import android.annotation.SuppressLint
import android.os.Build
import android.service.quicksettings.Tile
import com.follow.clash.common.QuickAction
import com.follow.clash.common.quickIntent
import com.follow.clash.common.toPendingIntent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
class TileService : android.service.quicksettings.TileService() {
private var scope: CoroutineScope? = null
override fun onStartListening() {
super.onStartListening()
scope?.cancel()
scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate).also { scope ->
scope.launch {
ServiceState.refresh()
ServiceState.runState.collect(::updateTile)
}
}
}
override fun onClick() {
super.onClick()
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,196 +0,0 @@
package com.follow.clash.extensions
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.drawable.Drawable
import android.net.ConnectivityManager
import android.net.Network
import android.os.Build
import android.system.OsConstants.IPPROTO_TCP
import android.system.OsConstants.IPPROTO_UDP
import android.util.Base64
import androidx.core.graphics.drawable.toBitmap
import com.follow.clash.TempActivity
import com.follow.clash.models.CIDR
import com.follow.clash.models.Metadata
import com.follow.clash.models.VpnOptions
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.ByteArrayOutputStream
import java.net.Inet4Address
import java.net.Inet6Address
import java.net.InetAddress
import java.util.concurrent.locks.ReentrantLock
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
suspend fun Drawable.getBase64(): String {
val drawable = this
return withContext(Dispatchers.IO) {
val bitmap = drawable.toBitmap()
val byteArrayOutputStream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.PNG, 100, byteArrayOutputStream)
Base64.encodeToString(byteArrayOutputStream.toByteArray(), Base64.NO_WRAP)
}
}
fun Metadata.getProtocol(): Int? {
if (network.startsWith("tcp")) return IPPROTO_TCP
if (network.startsWith("udp")) return IPPROTO_UDP
return null
}
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 String.toCIDR(): CIDR {
val parts = split("/")
if (parts.size != 2) {
throw IllegalArgumentException("Invalid CIDR format")
}
val ipAddress = parts[0]
val prefixLength = parts[1].toIntOrNull()
?: throw IllegalArgumentException("Invalid prefix length")
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")
}
return CIDR(address, prefixLength)
}
fun ConnectivityManager.resolveDns(network: Network?): List<String> {
val properties = getLinkProperties(network) ?: return listOf()
return properties.dnsServers.map { it.asSocketAddressText(53) }
}
fun InetAddress.asSocketAddressText(port: Int): String {
return when (this) {
is Inet6Address ->
"[${numericToTextFormat(this.address)}]:$port"
is Inet4Address ->
"${this.hostAddress}:$port"
else -> throw IllegalArgumentException("Unsupported Inet type ${this.javaClass}")
}
}
fun Context.wrapAction(action: String): String {
return "${this.packageName}.action.$action"
}
fun Context.getActionIntent(action: String): Intent {
val actionIntent = Intent(this, TempActivity::class.java)
actionIntent.action = wrapAction(action)
return actionIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK)
}
fun Context.getActionPendingIntent(action: String): PendingIntent {
return if (Build.VERSION.SDK_INT >= 31) {
PendingIntent.getActivity(
this,
0,
getActionIntent(action),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
} else {
PendingIntent.getActivity(
this,
0,
getActionIntent(action),
PendingIntent.FLAG_UPDATE_CURRENT
)
}
}
private fun numericToTextFormat(src: ByteArray): String {
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(":")
}
}
return sb.toString()
}
suspend fun <T> MethodChannel.awaitResult(
method: String,
arguments: Any? = null
): T? = withContext(Dispatchers.Main) { // 切换到主线程
suspendCoroutine { 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)
}
})
}
}
fun ReentrantLock.safeLock() {
if (this.isLocked) {
return
}
this.lock()
}
fun ReentrantLock.safeUnlock() {
if (!this.isLocked) {
return
}
this.unlock()
}
@@ -1,8 +1,9 @@
package com.follow.clash.models
data class Package(
data class InstalledPackage(
val packageName: String,
val label: String,
val isSystem: Boolean,
val system: Boolean,
val internet: Boolean,
val lastUpdateTime: Long,
)
@@ -1,15 +0,0 @@
package com.follow.clash.models
data class Process(
val id: String,
val metadata: Metadata,
)
data class Metadata(
val network: String,
val sourceIP: String,
val sourcePort: Int,
val destinationIP: String,
val destinationPort: Int,
val host: String
)
@@ -1,34 +0,0 @@
package com.follow.clash.models
import java.net.InetAddress
enum class AccessControlMode {
acceptSelected, rejectSelected,
}
data class AccessControl(
val enable: Boolean,
val mode: AccessControlMode,
val acceptList: List<String>,
val rejectList: List<String>,
)
data class CIDR(val address: InetAddress, val prefixLength: Int)
data class VpnOptions(
val enable: Boolean,
val port: Int,
val accessControl: AccessControl,
val allowBypass: Boolean,
val systemProxy: Boolean,
val bypassDomain: List<String>,
val routeAddress: List<String>,
val ipv4Address: String,
val ipv6Address: String,
val dnsServerAddress: String,
)
data class StartForegroundParams(
val title: String,
val content: String,
)
@@ -0,0 +1,22 @@
package com.follow.clash.models
import com.follow.clash.service.models.VpnOptions
import com.google.gson.annotations.SerializedName
data class SharedState(
val startTip: String = "Starting VPN...",
val stopTip: String = "Stopping VPN...",
val crashlytics: Boolean = true,
val currentProfileName: String = "FlClash",
val stopText: String = "Stop",
val onlyStatisticsProxy: Boolean = false,
val vpnOptions: VpnOptions? = null,
val setupParams: SetupParams? = null,
)
data class SetupParams(
@SerializedName("test-url")
val testUrl: String,
@SerializedName("selected-map")
val selectedMap: Map<String, String>,
)
@@ -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,32 +1,31 @@
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
import android.widget.Toast
import android.os.PowerManager
import android.provider.Settings
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.content.ContextCompat.getSystemService
import androidx.core.content.FileProvider
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import com.android.tools.smali.dexlib2.dexbacked.DexBackedDexFile
import com.follow.clash.FlClashApplication
import com.follow.clash.GlobalState
import androidx.core.net.toUri
import com.follow.clash.R
import com.follow.clash.extensions.awaitResult
import com.follow.clash.extensions.getActionIntent
import com.follow.clash.extensions.getBase64
import com.follow.clash.models.Package
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.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
@@ -35,126 +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 {
private var activityRef: WeakReference<Activity>? = null
private var activity: Activity? = null
private lateinit var channel: MethodChannel
private lateinit var scope: CoroutineScope
private var vpnCallBack: (() -> Unit)? = null
private var vpnPrepareCallback: ((Boolean) -> Unit)? = null
private val iconMap = mutableMapOf<String, String?>()
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()
}
val VPN_PERMISSION_REQUEST_CODE = 1001
val NOTIFICATION_PERMISSION_REQUEST_CODE = 1002
private var isBlockNotification: Boolean = false
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
scope = CoroutineScope(Dispatchers.Default)
channel = MethodChannel(flutterPluginBinding.binaryMessenger, "app")
channel.setMethodCallHandler(this)
}
private fun initShortcuts(label: String) {
val shortcut = ShortcutInfoCompat.Builder(FlClashApplication.getAppContext(), "toggle")
.setShortLabel(label)
.setIcon(
IconCompat.createWithResource(
FlClashApplication.getAppContext(),
R.mipmap.ic_launcher_round
)
)
.setIntent(FlClashApplication.getAppContext().getActionIntent("CHANGE"))
.build()
ShortcutManagerCompat.setDynamicShortcuts(
FlClashApplication.getAppContext(),
listOf(shortcut)
private val packageResolver by lazy {
PackageResolver(
GlobalState.application.packageManager,
GlobalState.application.packageName,
)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel.setMethodCallHandler(null)
scope.cancel()
}
private fun tip(message: String?) {
if (GlobalState.flutterEngine == null) {
Toast.makeText(FlClashApplication.getAppContext(), message, Toast.LENGTH_LONG).show()
}
}
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)
}
@@ -165,55 +77,51 @@ 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()))
}
}
"getPackageIcon" -> {
scope.launch {
val packageName = call.argument<String>("packageName")
if (packageName == null) {
result.success(null)
return@launch
}
val packageIcon = getPackageIcon(packageName)
packageIcon.let {
if (it != null) {
result.success(it)
return@launch
}
if (iconMap["default"] == null) {
iconMap["default"] =
FlClashApplication.getAppContext().packageManager?.defaultActivityIcon?.getBase64()
}
result.success(iconMap["default"])
return@launch
}
}
handleGetPackageIcon(call, result)
}
"tip" -> {
val message = call.argument<String>("message")
tip(message)
GlobalState.application.showToast(message)
result.success(true)
}
"openFile" -> {
val path = call.argument<String>("path")!!
openFile(path)
result.success(true)
"isBatteryOptimizationDisabled" -> {
result.success(isBatteryOptimizationDisabled())
}
"openBatteryOptimizationSettings" -> {
result.success(openBatteryOptimizationSettings())
}
"openAppSettings" -> {
result.success(openAppSettings())
}
"didCrashOnPreviousExecution" -> {
result.success(GlobalState.didCrashOnPreviousExecution())
}
else -> {
@@ -222,250 +130,206 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
}
}
private fun openFile(path: String) {
val file = File(path)
val uri = FileProvider.getUriForFile(
FlClashApplication.getAppContext(),
"${FlClashApplication.getAppContext().packageName}.fileProvider",
file
)
val intent = Intent(Intent.ACTION_VIEW).setDataAndType(
uri,
"text/plain"
)
val flags =
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION
val resInfoList = FlClashApplication.getAppContext().packageManager.queryIntentActivities(
intent, PackageManager.MATCH_DEFAULT_ONLY
)
for (resolveInfo in resInfoList) {
val packageName = resolveInfo.activityInfo.packageName
FlClashApplication.getAppContext().grantUriPermission(
packageName,
uri,
flags
)
}
try {
activityRef?.get()?.startActivity(intent)
} catch (e: Exception) {
println(e)
private fun handleGetPackageIcon(call: MethodCall, result: Result) {
scope.launch {
val packageName = call.argument<String>("packageName")
if (packageName == null) {
result.success("")
return@launch
}
val path = GlobalState.application.packageManager.getPackageIconPath(packageName)
result.success(path)
}
}
private fun initShortcuts(label: String) {
val shortcut = with(ShortcutInfoCompat.Builder(GlobalState.application, "toggle")) {
setShortLabel(label)
setIcon(
IconCompat.createWithResource(
GlobalState.application,
R.mipmap.ic_launcher_round,
),
)
setIntent(QuickAction.TOGGLE.quickIntent)
build()
}
ShortcutManagerCompat.setDynamicShortcuts(
GlobalState.application,
listOf(shortcut),
)
}
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()
}
activity.startActivity(intent)
true
} catch (_: Exception) {
false
}
}
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()
}
activity.startActivity(intent)
true
} catch (_: Exception) {
false
}
}
@Suppress("DEPRECATION")
private fun updateExcludeFromRecents(value: Boolean?) {
val am = getSystemService(FlClashApplication.getAppContext(), ActivityManager::class.java)
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 suspend fun getPackageIcon(packageName: String): String? {
val packageManager = FlClashApplication.getAppContext().packageManager
if (iconMap[packageName] == null) {
iconMap[packageName] = try {
packageManager?.getApplicationIcon(packageName)?.getBase64()
} catch (_: Exception) {
null
}
}
return iconMap[packageName]
}
private fun getPackages(): List<Package> {
val packageManager = FlClashApplication.getAppContext().packageManager
if (packages.isNotEmpty()) return packages
packageManager?.getInstalledPackages(PackageManager.GET_META_DATA or PackageManager.GET_PERMISSIONS)
?.filter {
it.packageName != FlClashApplication.getAppContext().packageName && (
it.requestedPermissions?.contains(Manifest.permission.INTERNET) == true
|| it.packageName == "android"
)
}?.map {
Package(
packageName = it.packageName,
label = it.applicationInfo?.loadLabel(packageManager).toString(),
isSystem = (it.applicationInfo?.flags?.and(ApplicationInfo.FLAG_SYSTEM)) == 1,
lastUpdateTime = it.lastUpdateTime
)
}?.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 requestVpnPermission(callBack: () -> Unit) {
vpnCallBack = callBack
val intent = VpnService.prepare(FlClashApplication.getAppContext())
if (intent != null) {
activityRef?.get()?.startActivityForResult(intent, VPN_PERMISSION_REQUEST_CODE)
return
}
vpnCallBack?.invoke()
}
fun requestNotificationsPermission() {
fun requestNotificationPermission(callback: () -> Unit) {
requestNotificationCallback?.invoke()
requestNotificationCallback = callback
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val permission = ContextCompat.checkSelfPermission(
FlClashApplication.getAppContext(),
Manifest.permission.POST_NOTIFICATIONS
GlobalState.application,
Manifest.permission.POST_NOTIFICATIONS,
)
if (permission != PackageManager.PERMISSION_GRANTED) {
if (isBlockNotification) return
if (activityRef?.get() == null) return
activityRef?.get()?.let {
ActivityCompat.requestPermissions(
it,
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
NOTIFICATION_PERMISSION_REQUEST_CODE
)
return
}
if (permission == PackageManager.PERMISSION_GRANTED || skipNotificationPermissionRequest) {
invokeRequestNotificationCallback()
return
}
}
}
suspend fun getText(text: String): String? {
return withContext(Dispatchers.Default) {
channel.awaitResult<String>("getText", text)
}
}
private fun isChinaPackage(packageName: String): Boolean {
val packageManager = FlClashApplication.getAppContext().packageManager ?: return false
skipPrefixList.forEach {
if (packageName == it || packageName.startsWith("$it.")) return false
}
val packageManagerFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
PackageManager.MATCH_UNINSTALLED_PACKAGES or PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES or PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS
} else {
@Suppress("DEPRECATION")
PackageManager.GET_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())
if (isRequestingNotificationPermission) {
return
}
isRequestingNotificationPermission = true
activity?.let {
ActivityCompat.requestPermissions(
it,
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
NOTIFICATION_PERMISSION_REQUEST_CODE,
)
} ?: invokeRequestNotificationCallback()
return
}
invokeRequestNotificationCallback()
}
private fun invokeRequestNotificationCallback() {
isRequestingNotificationPermission = false
requestNotificationCallback?.invoke()
requestNotificationCallback = null
}
fun prepareVpn(needPrepare: Boolean, callback: (Boolean) -> Unit) {
vpnPrepareCallback = callback
if (!needPrepare) {
invokeVpnPrepareCallback(true)
return
}
val intent = VpnService.prepare(GlobalState.application)
if (intent != null) {
val activity = activity
if (activity == null) {
invokeVpnPrepareCallback(false)
} else {
packageManager.getPackageInfo(
packageName, packageManagerFlags
)
@Suppress("DEPRECATION")
activity.startActivityForResult(intent, VPN_PERMISSION_REQUEST_CODE)
}
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
}
return false
invokeVpnPrepareCallback(true)
}
private fun invokeVpnPrepareCallback(granted: Boolean) {
vpnPrepareCallback?.invoke(granted)
vpnPrepareCallback = null
}
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
channel =
MethodChannel(flutterPluginBinding.binaryMessenger, "${Components.PACKAGE_NAME}/app")
channel.setMethodCallHandler(this)
}
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) {
GlobalState.initServiceEngine()
vpnCallBack?.invoke()
}
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
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,58 +1,112 @@
package com.follow.clash.plugins
import com.follow.clash.GlobalState
import com.follow.clash.models.VpnOptions
import com.follow.clash.ServiceController
import com.follow.clash.ServiceState
import com.follow.clash.common.Components
import com.follow.clash.models.SharedState
import com.google.gson.Gson
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodCall
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
class ServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
private lateinit var channel: MethodChannel
private lateinit var scope: CoroutineScope
private val gson = Gson()
data object ServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
private lateinit var flutterMethodChannel: MethodChannel
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
flutterMethodChannel = MethodChannel(flutterPluginBinding.binaryMessenger, "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) {
"startVpn" -> {
val data = call.argument<String>("data")
val options = Gson().fromJson(data, VpnOptions::class.java)
GlobalState.getCurrentVPNPlugin()?.handleStart(options)
result.success(true)
}
"stopVpn" -> {
GlobalState.getCurrentVPNPlugin()?.handleStop()
result.success(true)
}
"init" -> {
GlobalState.getCurrentAppPlugin()
?.requestNotificationsPermission()
GlobalState.initServiceEngine()
result.success(true)
}
"destroy" -> {
handleDestroy()
result.success(true)
}
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 handleDestroy() {
GlobalState.getCurrentVPNPlugin()?.handleStop()
GlobalState.destroyServiceEngine()
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 getRunTime(result: MethodChannel.Result) {
scope.launch {
ServiceState.refresh()
result.success(ServiceState.runTimeMillis)
}
}
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("")
}
}
private fun start(result: MethodChannel.Result) {
ServiceState.requestStart()
result.success(true)
}
private fun stop(result: MethodChannel.Result) {
ServiceState.requestStop()
result.success(true)
}
private fun sendEvent(value: String?) {
scope.launch(Dispatchers.Main) {
channel.invokeMethod("event", value)
}
}
}
@@ -1,35 +1,33 @@
package com.follow.clash.plugins
import com.follow.clash.common.Components
import com.follow.clash.invokeMethodOnMainThread
import io.flutter.embedding.engine.plugins.FlutterPlugin
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) {
channel = MethodChannel(flutterPluginBinding.binaryMessenger, "tile")
channel =
MethodChannel(flutterPluginBinding.binaryMessenger, "${Components.PACKAGE_NAME}/tile")
channel.setMethodCallHandler(this)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
handleDetached()
channel.setMethodCallHandler(null)
}
fun handleStart() {
channel.invokeMethod("start", null)
channel.invokeMethodOnMainThread("start")
}
fun handleStop() {
channel.invokeMethod("stop", null)
channel.invokeMethodOnMainThread("stop")
}
private fun handleDetached() {
channel.invokeMethod("detached", null)
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
result.notImplemented()
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {}
}
}
@@ -1,284 +0,0 @@
package com.follow.clash.plugins
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.Build
import android.os.IBinder
import androidx.core.content.getSystemService
import com.follow.clash.FlClashApplication
import com.follow.clash.GlobalState
import com.follow.clash.RunState
import com.follow.clash.extensions.awaitResult
import com.follow.clash.extensions.getProtocol
import com.follow.clash.extensions.resolveDns
import com.follow.clash.models.Process
import com.follow.clash.models.StartForegroundParams
import com.follow.clash.models.VpnOptions
import com.follow.clash.services.BaseServiceInterface
import com.follow.clash.services.FlClashService
import com.follow.clash.services.FlClashVpnService
import com.google.gson.Gson
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.net.InetSocketAddress
import kotlin.concurrent.withLock
data object VpnPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
private lateinit var flutterMethodChannel: MethodChannel
private var flClashService: BaseServiceInterface? = null
private lateinit var options: VpnOptions
private lateinit var scope: CoroutineScope
private var lastStartForegroundParams: StartForegroundParams? = null
private var timerJob: Job? = null
private val connectivity by lazy {
FlClashApplication.getAppContext().getSystemService<ConnectivityManager>()
}
private val connection = object : ServiceConnection {
override fun onServiceConnected(className: ComponentName, service: IBinder) {
flClashService = when (service) {
is FlClashVpnService.LocalBinder -> service.getService()
is FlClashService.LocalBinder -> service.getService()
else -> throw Exception("invalid binder")
}
handleStartService()
}
override fun onServiceDisconnected(arg: ComponentName) {
flClashService = null
}
}
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
scope = CoroutineScope(Dispatchers.Default)
scope.launch {
registerNetworkCallback()
}
flutterMethodChannel = MethodChannel(flutterPluginBinding.binaryMessenger, "vpn")
flutterMethodChannel.setMethodCallHandler(this)
}
override fun onDetachedFromEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
unRegisterNetworkCallback()
flutterMethodChannel.setMethodCallHandler(null)
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"start" -> {
val data = call.argument<String>("data")
result.success(handleStart(Gson().fromJson(data, VpnOptions::class.java)))
}
"stop" -> {
handleStop()
result.success(true)
}
"setProtect" -> {
val fd = call.argument<Int>("fd")
if (fd != null && flClashService is FlClashVpnService) {
try {
(flClashService as FlClashVpnService).protect(fd)
result.success(true)
} catch (e: RuntimeException) {
result.success(false)
}
} else {
result.success(false)
}
}
"resolverProcess" -> {
val data = call.argument<String>("data")
val process = if (data != null) Gson().fromJson(
data, Process::class.java
) else null
val metadata = process?.metadata
if (metadata == null) {
result.success(null)
return
}
val protocol = metadata.getProtocol()
if (protocol == null) {
result.success(null)
return
}
scope.launch {
withContext(Dispatchers.Default) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
result.success(null)
return@withContext
}
val src = InetSocketAddress(metadata.sourceIP, metadata.sourcePort)
val dst = InetSocketAddress(
metadata.destinationIP.ifEmpty { metadata.host },
metadata.destinationPort
)
val uid = try {
connectivity?.getConnectionOwnerUid(protocol, src, dst)
} catch (_: Exception) {
null
}
if (uid == null || uid == -1) {
result.success(null)
return@withContext
}
val packages =
FlClashApplication.getAppContext().packageManager?.getPackagesForUid(uid)
result.success(packages?.first())
}
}
}
else -> {
result.notImplemented()
}
}
}
fun handleStart(options: VpnOptions): Boolean {
this.options = options
when (options.enable) {
true -> handleStartVpn()
false -> handleStartService()
}
return true
}
private fun handleStartVpn() {
GlobalState.getCurrentAppPlugin()
?.requestVpnPermission {
handleStartService()
}
}
fun requestGc() {
flutterMethodChannel.invokeMethod("gc", null)
}
val networks = mutableSetOf<Network>()
fun onUpdateNetwork() {
val dns = networks.flatMap { network ->
connectivity?.resolveDns(network) ?: emptyList()
}.toSet().joinToString(",")
scope.launch {
withContext(Dispatchers.Main) {
flutterMethodChannel.invokeMethod("dnsChanged", dns)
}
}
}
private val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
networks.add(network)
onUpdateNetwork()
}
override fun onLost(network: Network) {
networks.remove(network)
onUpdateNetwork()
}
}
private val request = NetworkRequest.Builder().apply {
addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED)
}.build()
private fun registerNetworkCallback() {
networks.clear()
connectivity?.registerNetworkCallback(request, callback)
}
private fun unRegisterNetworkCallback() {
connectivity?.unregisterNetworkCallback(callback)
networks.clear()
onUpdateNetwork()
}
private suspend fun startForeground() {
GlobalState.runLock.lock()
try {
if (GlobalState.runState.value != RunState.START) return
val data = flutterMethodChannel.awaitResult<String>("getStartForegroundParams")
val startForegroundParams = Gson().fromJson(
data, StartForegroundParams::class.java
)
if (lastStartForegroundParams != startForegroundParams) {
lastStartForegroundParams = startForegroundParams
flClashService?.startForeground(
startForegroundParams.title,
startForegroundParams.content,
)
}
} finally {
GlobalState.runLock.unlock()
}
}
private fun startForegroundJob() {
timerJob = CoroutineScope(Dispatchers.Main).launch {
while (isActive) {
startForeground()
delay(1000)
}
}
}
private fun stopForegroundJob() {
timerJob?.cancel()
timerJob = null
}
private fun handleStartService() {
if (flClashService == null) {
bindService()
return
}
GlobalState.runLock.withLock {
if (GlobalState.runState.value == RunState.START) return
GlobalState.runState.value = RunState.START
val fd = flClashService?.start(options)
flutterMethodChannel.invokeMethod(
"started", fd
)
startForegroundJob();
}
}
fun handleStop() {
GlobalState.runLock.withLock {
if (GlobalState.runState.value == RunState.STOP) return
GlobalState.runState.value = RunState.STOP
stopForegroundJob()
flClashService?.stop()
GlobalState.handleTryDestroy()
}
}
private fun bindService() {
val intent = when (options.enable) {
true -> Intent(FlClashApplication.getAppContext(), FlClashVpnService::class.java)
false -> Intent(FlClashApplication.getAppContext(), FlClashService::class.java)
}
FlClashApplication.getAppContext().bindService(intent, connection, Context.BIND_AUTO_CREATE)
}
}
@@ -1,12 +0,0 @@
package com.follow.clash.services
import com.follow.clash.models.VpnOptions
interface BaseServiceInterface {
fun start(options: VpnOptions): Int
fun stop()
suspend fun startForeground(title: String, content: String)
}
@@ -1,130 +0,0 @@
package com.follow.clash.services
import android.annotation.SuppressLint
import android.app.Notification.FOREGROUND_SERVICE_IMMEDIATE
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
import android.os.Binder
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import com.follow.clash.GlobalState
import com.follow.clash.MainActivity
import com.follow.clash.extensions.getActionPendingIntent
import com.follow.clash.models.VpnOptions
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
class FlClashService : Service(), BaseServiceInterface {
private val binder = LocalBinder()
inner class LocalBinder : Binder() {
fun getService(): FlClashService = this@FlClashService
}
override fun onBind(intent: Intent): IBinder {
return binder
}
override fun onUnbind(intent: Intent?): Boolean {
return super.onUnbind(intent)
}
private val CHANNEL = "FlClash"
private val notificationId: Int = 1
private val notificationBuilderDeferred: Deferred<NotificationCompat.Builder> by lazy {
CoroutineScope(Dispatchers.Main).async {
val stopText = GlobalState.getText("stop")
val intent = Intent(
this@FlClashService, MainActivity::class.java
)
val pendingIntent = if (Build.VERSION.SDK_INT >= 31) {
PendingIntent.getActivity(
this@FlClashService,
0,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
} else {
PendingIntent.getActivity(
this@FlClashService,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT
)
}
with(NotificationCompat.Builder(this@FlClashService, CHANNEL)) {
setSmallIcon(com.follow.clash.R.drawable.ic_stat_name)
setContentTitle("FlClash")
setContentIntent(pendingIntent)
setCategory(NotificationCompat.CATEGORY_SERVICE)
priority = NotificationCompat.PRIORITY_MIN
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
foregroundServiceBehavior = FOREGROUND_SERVICE_IMMEDIATE
}
addAction(
0,
stopText, // 使用 suspend 函数获取的文本
getActionPendingIntent("STOP")
)
setOngoing(true)
setShowWhen(false)
setOnlyAlertOnce(true)
setAutoCancel(true)
}
}
}
private suspend fun getNotificationBuilder(): NotificationCompat.Builder {
return notificationBuilderDeferred.await()
}
override fun start(options: VpnOptions) = 0
override fun stop() {
stopSelf()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
stopForeground(STOP_FOREGROUND_REMOVE)
}
}
@SuppressLint("ForegroundServiceType")
override suspend fun startForeground(title: String, content: String) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val manager = getSystemService(NotificationManager::class.java)
var channel = manager?.getNotificationChannel(CHANNEL)
if (channel == null) {
channel =
NotificationChannel(CHANNEL, "FlClash", NotificationManager.IMPORTANCE_LOW)
manager?.createNotificationChannel(channel)
}
}
val notification =
getNotificationBuilder()
.setContentTitle(title)
.setContentText(content).build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
try {
startForeground(notificationId, notification, FOREGROUND_SERVICE_TYPE_DATA_SYNC)
} catch (_: Exception) {
startForeground(notificationId, notification)
}
} else {
startForeground(notificationId, notification)
}
}
}
@@ -1,76 +0,0 @@
package com.follow.clash.services
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.content.Intent
import android.os.Build
import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import androidx.annotation.RequiresApi
import androidx.lifecycle.Observer
import com.follow.clash.GlobalState
import com.follow.clash.RunState
import com.follow.clash.TempActivity
@RequiresApi(Build.VERSION_CODES.N)
class FlClashTileService : TileService() {
private val observer = Observer<RunState> { runState ->
updateTile(runState)
}
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()
GlobalState.runState.value?.let { updateTile(it) }
GlobalState.runState.observeForever(observer)
}
@SuppressLint("StartActivityAndCollapseDeprecated")
private fun activityTransfer() {
val intent = Intent(this, TempActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK)
val pendingIntent = if (Build.VERSION.SDK_INT >= 31) {
PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
} else {
PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT
)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startActivityAndCollapse(pendingIntent)
} else {
startActivityAndCollapse(intent)
}
}
override fun onClick() {
super.onClick()
activityTransfer()
GlobalState.handleToggle()
}
override fun onDestroy() {
GlobalState.runState.removeObserver(observer)
super.onDestroy()
}
}
@@ -1,246 +0,0 @@
package com.follow.clash.services
import android.annotation.SuppressLint
import android.app.Notification.FOREGROUND_SERVICE_IMMEDIATE
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Intent
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
import android.net.ProxyInfo
import android.net.VpnService
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.app.NotificationCompat
import com.follow.clash.GlobalState
import com.follow.clash.MainActivity
import com.follow.clash.R
import com.follow.clash.extensions.getActionPendingIntent
import com.follow.clash.extensions.getIpv4RouteAddress
import com.follow.clash.extensions.getIpv6RouteAddress
import com.follow.clash.extensions.toCIDR
import com.follow.clash.models.AccessControlMode
import com.follow.clash.models.VpnOptions
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
class FlClashVpnService : VpnService(), BaseServiceInterface {
override fun onCreate() {
super.onCreate()
GlobalState.initServiceEngine()
}
override fun start(options: VpnOptions): Int {
return with(Builder()) {
if (options.ipv4Address.isNotEmpty()) {
val cidr = options.ipv4Address.toCIDR()
addAddress(cidr.address, 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("0.0.0.0", 0)
}
} else {
addRoute("0.0.0.0", 0)
}
}
if (options.ipv6Address.isNotEmpty()) {
val cidr = options.ipv6Address.toCIDR()
addAddress(cidr.address, cidr.prefixLength)
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("::", 0)
}
}
addDnsServer(options.dnsServerAddress)
setMtu(9000)
options.accessControl.let { accessControl ->
if (accessControl.enable) {
when (accessControl.mode) {
AccessControlMode.acceptSelected -> {
(accessControl.acceptList + packageName).forEach {
addAllowedApplication(it)
}
}
AccessControlMode.rejectSelected -> {
(accessControl.rejectList - packageName).forEach {
addDisallowedApplication(it)
}
}
}
}
}
setSession("FlClash")
setBlocking(false)
if (Build.VERSION.SDK_INT >= 29) {
setMetered(false)
}
if (options.allowBypass) {
allowBypass()
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && options.systemProxy) {
setHttpProxy(
ProxyInfo.buildDirectProxy(
"127.0.0.1",
options.port,
options.bypassDomain
)
)
}
establish()?.detachFd()
?: throw NullPointerException("Establish VPN rejected by system")
}
}
override fun stop() {
stopSelf()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
stopForeground(STOP_FOREGROUND_REMOVE)
}
}
private val CHANNEL = "FlClash"
private val notificationId: Int = 1
private val notificationBuilderDeferred: Deferred<NotificationCompat.Builder> by lazy {
CoroutineScope(Dispatchers.Main).async {
val stopText = GlobalState.getText("stop")
val intent = Intent(this@FlClashVpnService, MainActivity::class.java)
val pendingIntent = if (Build.VERSION.SDK_INT >= 31) {
PendingIntent.getActivity(
this@FlClashVpnService,
0,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
} else {
PendingIntent.getActivity(
this@FlClashVpnService,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT
)
}
with(NotificationCompat.Builder(this@FlClashVpnService, CHANNEL)) {
setSmallIcon(R.drawable.ic_stat_name)
setContentTitle("FlClash")
setContentIntent(pendingIntent)
setCategory(NotificationCompat.CATEGORY_SERVICE)
priority = NotificationCompat.PRIORITY_MIN
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
foregroundServiceBehavior = FOREGROUND_SERVICE_IMMEDIATE
}
setOngoing(true)
addAction(
0,
stopText,
getActionPendingIntent("STOP")
)
setShowWhen(false)
setOnlyAlertOnce(true)
setAutoCancel(true)
}
}
}
private suspend fun getNotificationBuilder(): NotificationCompat.Builder {
return notificationBuilderDeferred.await()
}
@SuppressLint("ForegroundServiceType")
override suspend fun startForeground(title: String, content: String) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val manager = getSystemService(NotificationManager::class.java)
var channel = manager?.getNotificationChannel(CHANNEL)
if (channel == null) {
channel =
NotificationChannel(CHANNEL, "FlClash", NotificationManager.IMPORTANCE_LOW)
manager?.createNotificationChannel(channel)
}
}
val notification =
getNotificationBuilder()
.setContentTitle(title)
.setContentText(content)
.build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
try {
startForeground(notificationId, notification, FOREGROUND_SERVICE_TYPE_DATA_SYNC)
} catch (_: Exception) {
startForeground(notificationId, notification)
}
} else {
startForeground(notificationId, notification)
}
}
override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
GlobalState.getCurrentVPNPlugin()?.requestGc()
}
private val binder = LocalBinder()
inner class LocalBinder : Binder() {
fun getService(): FlClashVpnService = this@FlClashVpnService
override fun onTransact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean {
try {
val isSuccess = super.onTransact(code, data, reply, flags)
if (!isSuccess) {
CoroutineScope(Dispatchers.Main).launch {
GlobalState.getCurrentTilePlugin()?.handleStop()
}
}
return isSuccess
} catch (e: RemoteException) {
throw e
}
}
}
override fun onBind(intent: Intent): IBinder {
return binder
}
override fun onUnbind(intent: Intent?): Boolean {
return super.onUnbind(intent)
}
override fun onDestroy() {
stop()
super.onDestroy()
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 618 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 423 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 803 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

@@ -1,25 +1,25 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="240"
android:viewportHeight="240">
<group android:scaleX="0.924"
android:scaleY="0.924"
android:translateX="9.12"
android:translateY="9.12">
<group android:scaleX="0.63461536"
android:scaleY="0.63461536"
android:translateX="45.96154"
android:translateY="43.846153">
<path
android:pathData="M60.65,89.6L154.18,35.6A18,18 107.59,0 1,178.77 42.19L178.77,42.19A18,18 107.59,0 1,172.18 66.78L78.65,120.78A18,18 106.67,0 1,54.06 114.19L54.06,114.19A18,18 106.67,0 1,60.65 89.6z"
android:fillColor="#6666FB"/>
<path
android:pathData="M84.65,131.17L131.42,104.17A18,18 107.83,0 1,156 110.76L156,110.76A18,18 107.83,0 1,149.42 135.35L102.65,162.35A18,18 106.67,0 1,78.06 155.76L78.06,155.76A18,18 106.67,0 1,84.65 131.17z"
android:fillColor="#336AB6"/>
<path
android:pathData="M108.65,172.74L108.65,172.74A18,18 116.03,0 1,133.24 179.33L133.24,179.33A18,18 116.03,0 1,126.65 203.92L126.65,203.92A18,18 116.03,0 1,102.06 197.33L102.06,197.33A18,18 116.03,0 1,108.65 172.74z"
android:fillColor="#5CA8E9"/>
android:width="108dp"
android:height="108dp"
android:viewportWidth="240"
android:viewportHeight="240">
<group android:scaleX="0.924"
android:scaleY="0.924"
android:translateX="9.12"
android:translateY="9.12">
<group android:scaleX="0.63461536"
android:scaleY="0.63461536"
android:translateX="45.96154"
android:translateY="43.846153">
<path
android:pathData="M60.65,89.6L154.18,35.6A18,18 107.59,0 1,178.77 42.19L178.77,42.19A18,18 107.59,0 1,172.18 66.78L78.65,120.78A18,18 106.67,0 1,54.06 114.19L54.06,114.19A18,18 106.67,0 1,60.65 89.6z"
android:fillColor="#6666FB"/>
<path
android:pathData="M84.65,131.17L131.42,104.17A18,18 107.83,0 1,156 110.76L156,110.76A18,18 107.83,0 1,149.42 135.35L102.65,162.35A18,18 106.67,0 1,78.06 155.76L78.06,155.76A18,18 106.67,0 1,84.65 131.17z"
android:fillColor="#336AB6"/>
<path
android:pathData="M108.65,172.74L108.65,172.74A18,18 116.03,0 1,133.24 179.33L133.24,179.33A18,18 116.03,0 1,126.65 203.92L126.65,203.92A18,18 116.03,0 1,102.06 197.33L102.06,197.33A18,18 116.03,0 1,108.65 172.74z"
android:fillColor="#5CA8E9"/>
</group>
</group>
</group>
</vector>
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="fl_clash">FlClash</string>
</resources>
@@ -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>
-32
View File
@@ -1,32 +0,0 @@
buildscript {
ext.kotlin_version = "${kotlin_version}"
repositories {
google()
mavenCentral()
}
dependencies {
classpath "com.android.tools.build:gradle:$agp_version"
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
allprojects {
repositories {
google()
mavenCentral()
}
}
rootProject.buildDir = '../build'
subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}"
}
subprojects {
project.evaluationDependsOn(':app')
}
tasks.register("clean", Delete) {
delete rootProject.buildDir
}
+36
View File
@@ -0,0 +1,36 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
project.evaluationDependsOn(":app")
}
gradle.projectsEvaluated {
allprojects {
tasks.withType<JavaCompile>().configureEach {
sourceCompatibility = JavaVersion.VERSION_17.toString()
targetCompatibility = JavaVersion.VERSION_17.toString()
}
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}
}
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
+1
View File
@@ -0,0 +1 @@
/build
+33
View File
@@ -0,0 +1,33 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
id("com.android.library")
}
android {
namespace = "com.follow.clash.common"
compileSdk = libs.versions.compileSdk.get().toInt()
defaultConfig {
minSdk = libs.versions.minSdk.get().toInt()
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}
dependencies {
implementation(libs.androidx.core)
implementation(libs.gson)
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.crashlytics.ndk)
implementation(libs.firebase.analytics)
}
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<permission
android:name="${applicationId}.permission.RECEIVE_BROADCASTS"
android:protectionLevel="signature" />
<uses-permission android:name="${applicationId}.permission.RECEIVE_BROADCASTS" />
</manifest>
@@ -0,0 +1,16 @@
package com.follow.clash.common
import android.content.ComponentName
object Components {
const val PACKAGE_NAME = "com.follow.clash"
val mainActivity =
ComponentName(GlobalState.packageName, "${PACKAGE_NAME}.MainActivity")
val quickActionActivity =
ComponentName(GlobalState.packageName, "${PACKAGE_NAME}.QuickActionActivity")
val serviceBroadcastReceiver =
ComponentName(GlobalState.packageName, "${PACKAGE_NAME}.ServiceBroadcastReceiver")
}
@@ -0,0 +1,22 @@
package com.follow.clash.common
import com.google.gson.annotations.SerializedName
enum class QuickAction {
STOP,
START,
TOGGLE,
}
enum class BroadcastAction {
SERVICE_CREATED,
SERVICE_DESTROYED,
}
enum class AccessControlMode {
@SerializedName("acceptSelected")
ACCEPT_SELECTED,
@SerializedName("rejectSelected")
REJECT_SELECTED,
}
@@ -0,0 +1,116 @@
package com.follow.clash.common
import android.annotation.SuppressLint
import android.app.ActivityManager
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
import android.content.Context.RECEIVER_NOT_EXPORTED
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
import android.os.Build
import androidx.core.content.getSystemService
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlin.reflect.KClass
val KClass<*>.intent: Intent
get() = Intent(GlobalState.application, this.java)
val ComponentName.intent: Intent
get() = Intent().apply {
component = this@intent
}
val QuickAction.action: String
get() = "${GlobalState.application.packageName}.action.${this.name}"
val QuickAction.quickIntent: Intent
get() = Components.quickActionActivity.intent.apply {
action = this@quickIntent.action
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
val BroadcastAction.action: String
get() = "${GlobalState.application.packageName}.intent.action.${this.name}"
val Context.processName: String?
get() = getSystemService<ActivityManager>()
?.runningAppProcesses
?.firstOrNull { it.pid == android.os.Process.myPid() }
?.processName
fun BroadcastAction.sendBroadcast() {
val broadcastAction = action
val intent = Intent(broadcastAction).apply {
component = Components.serviceBroadcastReceiver
}
GlobalState.log("Send broadcast: $broadcastAction")
GlobalState.application.sendBroadcast(
intent,
GlobalState.receiveBroadcastPermission,
)
}
val Intent.toPendingIntent: PendingIntent
get() = PendingIntent.getActivity(
GlobalState.application,
0,
this,
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)
var channel = manager?.getNotificationChannel(GlobalState.NOTIFICATION_CHANNEL)
if (channel == null) {
channel = NotificationChannel(
GlobalState.NOTIFICATION_CHANNEL,
getString(R.string.service_channel_name),
NotificationManager.IMPORTANCE_LOW,
)
manager?.createNotificationChannel(channel)
}
}
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")
fun Context.registerReceiverCompat(
receiver: BroadcastReceiver,
filter: IntentFilter,
) = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(receiver, filter, RECEIVER_NOT_EXPORTED)
} else {
registerReceiver(receiver, filter)
}
fun Context.receiveBroadcastFlow(
configure: IntentFilter.() -> Unit,
): Flow<Intent> = callbackFlow {
val filter = IntentFilter().apply(configure)
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (context == null || intent == null) return
trySend(intent)
}
}
registerReceiverCompat(receiver, filter)
awaitClose { unregisterReceiver(receiver) }
}
@@ -0,0 +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(SupervisorJob() + Dispatchers.Default) {
const val NOTIFICATION_CHANNEL = "FlClash"
const val NOTIFICATION_ID = 1
val packageName: String
get() = application.packageName
val receiveBroadcastPermission: String
get() = "$packageName.permission.RECEIVE_BROADCASTS"
val application: Application
get() = checkNotNull(appInstance) { "GlobalState is not initialized" }
@Volatile
private var appInstance: Application? = null
fun init(application: Application) {
appInstance = application
}
fun log(text: String) {
Log.d("FlClash", text)
}
fun setCrashlytics(enable: Boolean) {
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()
}
}
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">FlClash</string>
<string name="service_channel_name">FlClash Service</string>
</resources>
+1
View File
@@ -0,0 +1 @@
/build
+38
View File
@@ -0,0 +1,38 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
id("com.android.library")
}
android {
namespace = "com.follow.clash.core"
compileSdk = libs.versions.compileSdk.get().toInt()
ndkVersion = libs.versions.ndkVersion.get()
defaultConfig {
minSdk = libs.versions.minSdk.get().toInt()
}
externalNativeBuild {
cmake {
path("src/main/cpp/CMakeLists.txt")
version = "3.22.1"
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}
dependencies {
implementation(libs.annotation.jvm)
}
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
</manifest>
+38
View File
@@ -0,0 +1,38 @@
cmake_minimum_required(VERSION 3.22.1)
project("core")
message("CMAKE_SOURCE_DIR ${CMAKE_SOURCE_DIR}")
message("CMAKE_BUILD_TYPE ${CMAKE_BUILD_TYPE}")
if (NOT "${CMAKE_BUILD_TYPE}" STREQUAL "Debug")
set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
add_compile_options(-O3 -flto -g0 -fno-exceptions -fno-rtti)
add_link_options(-flto -Wl,--gc-sections,--strip-all)
endif ()
set(LIB_CLASH_PATH "${CMAKE_SOURCE_DIR}/../jniLibs/${ANDROID_ABI}/libclash.so")
set(LIB_CLASH_INCLUDE_DIR "${CMAKE_SOURCE_DIR}/../cpp/includes/${ANDROID_ABI}")
set(LIB_CLASH_HEADER_PATH "${LIB_CLASH_INCLUDE_DIR}/libclash.h")
set(BRIDE_HEADER_PATH "${LIB_CLASH_INCLUDE_DIR}/bride.h")
message("LIB_CLASH_PATH ${LIB_CLASH_PATH}")
if (EXISTS ${LIB_CLASH_PATH} AND EXISTS ${LIB_CLASH_HEADER_PATH} AND EXISTS ${BRIDE_HEADER_PATH})
message("Found libclash.so and headers for ABI ${ANDROID_ABI}")
add_compile_definitions(LIBCLASH)
include_directories(${LIB_CLASH_INCLUDE_DIR})
link_directories(${CMAKE_SOURCE_DIR}/../jniLibs/${ANDROID_ABI})
add_library(${CMAKE_PROJECT_NAME} SHARED
jni_helper.cpp
core.cpp)
target_link_libraries(${CMAKE_PROJECT_NAME}
clash)
else ()
message("Not found complete libclash output for ABI ${ANDROID_ABI}; building core without LIBCLASH")
add_library(${CMAKE_PROJECT_NAME} SHARED
jni_helper.cpp
core.cpp)
target_link_libraries(${CMAKE_PROJECT_NAME})
endif ()
+222
View File
@@ -0,0 +1,222 @@
#include <jni.h>
#ifdef LIBCLASH
#include "jni_helper.h"
#include "libclash.h"
#include "bride.h"
extern "C"
JNIEXPORT void JNICALL
Java_com_follow_clash_core_Core_startTun(JNIEnv *env, jobject thiz, jint fd, jobject cb,
jstring stack, jstring address, jstring dns) {
const auto interface = new_global(cb);
startTUN(interface, fd, get_string(stack), get_string(address), get_string(dns));
}
extern "C"
JNIEXPORT void JNICALL
Java_com_follow_clash_core_Core_stopTun(JNIEnv *env, jobject thiz) {
stopTun();
}
extern "C"
JNIEXPORT void JNICALL
Java_com_follow_clash_core_Core_forceGC(JNIEnv *env, jobject thiz) {
forceGC();
}
extern "C"
JNIEXPORT void JNICALL
Java_com_follow_clash_core_Core_updateDNS(JNIEnv *env, jobject thiz, jstring dns) {
updateDns(get_string(dns));
}
extern "C"
JNIEXPORT void JNICALL
Java_com_follow_clash_core_Core_invokeMethod(JNIEnv *env, jobject thiz, jstring data, jobject cb) {
const auto interface = new_global(cb);
invokeMethod(interface, get_string(data));
}
extern "C"
JNIEXPORT void JNICALL
Java_com_follow_clash_core_Core_setEventListener(JNIEnv *env, jobject thiz, jobject cb) {
if (cb != nullptr) {
const auto interface = new_global(cb);
setEventListener(interface);
} else {
setEventListener(nullptr);
}
}
extern "C"
JNIEXPORT jstring JNICALL
Java_com_follow_clash_core_Core_getTraffic(JNIEnv *env, jobject thiz,
const jboolean 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) {
scoped_string traffic = getTotalTraffic(only_statistics_proxy);
return new_string(traffic);
}
extern "C"
JNIEXPORT void JNICALL
Java_com_follow_clash_core_Core_suspended(JNIEnv *env, jobject thiz, jboolean suspended) {
suspend(suspended);
}
extern "C"
JNIEXPORT void JNICALL
Java_com_follow_clash_core_Core_quickSetup(JNIEnv *env, jobject thiz, jstring init_params_string,
jstring setup_params_string, jobject cb) {
const auto interface = new_global(cb);
quickSetup(interface, get_string(init_params_string), get_string(setup_params_string));
}
static jmethodID m_tun_interface_protect;
static jmethodID m_tun_interface_resolve_process;
static jmethodID m_invoke_interface_result;
static void release_jni_object_impl(void *obj) {
ATTACH_JNI();
del_global(static_cast<jobject>(obj));
}
static void free_string_impl(char *str) {
free(str);
}
static void call_tun_interface_protect_impl(void *tun_interface, const int fd) {
ATTACH_JNI();
env->CallVoidMethod(static_cast<jobject>(tun_interface),
m_tun_interface_protect,
fd);
}
static char *
call_tun_interface_resolve_process_impl(void *tun_interface, const int protocol,
const char *source,
const char *target,
const int uid) {
ATTACH_JNI();
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,
source_string,
target_string,
uid));
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,
value);
env->DeleteLocalRef(value);
}
extern "C"
JNIEXPORT jint JNICALL
JNI_OnLoad(JavaVM *vm, void *) {
JNIEnv *env = nullptr;
if (vm->GetEnv(reinterpret_cast<void **>(&env), JNI_VERSION_1_6) != JNI_OK) {
return JNI_ERR;
}
initialize_jni(vm, env);
const auto c_tun_interface = find_class("com/follow/clash/core/TunInterface");
const auto c_invoke_interface = find_class("com/follow/clash/core/InvokeInterface");
m_tun_interface_protect = find_method(c_tun_interface, "protect", "(I)V");
m_tun_interface_resolve_process = find_method(c_tun_interface, "resolverProcess",
"(ILjava/lang/String;Ljava/lang/String;I)Ljava/lang/String;");
m_invoke_interface_result = find_method(c_invoke_interface, "onResult",
"(Ljava/lang/String;)V");
protect_func = &call_tun_interface_protect_impl;
resolve_process_func = &call_tun_interface_resolve_process_impl;
result_func = &call_invoke_interface_result_impl;
release_object_func = &release_jni_object_impl;
free_string_func = &free_string_impl;
return JNI_VERSION_1_6;
}
#else
extern "C"
JNIEXPORT void JNICALL
Java_com_follow_clash_core_Core_startTun(JNIEnv *env, jobject thiz, jint fd, jobject cb,
jstring stack, jstring address, jstring dns) {
}
extern "C"
JNIEXPORT void JNICALL
Java_com_follow_clash_core_Core_stopTun(JNIEnv *env, jobject thiz) {
}
extern "C"
JNIEXPORT void JNICALL
Java_com_follow_clash_core_Core_invokeMethod(JNIEnv *env, jobject thiz, jstring data, jobject cb) {
}
extern "C"
JNIEXPORT void JNICALL
Java_com_follow_clash_core_Core_forceGC(JNIEnv *env, jobject thiz) {
}
extern "C"
JNIEXPORT void JNICALL
Java_com_follow_clash_core_Core_updateDNS(JNIEnv *env, jobject thiz, jstring dns) {
}
extern "C"
JNIEXPORT void JNICALL
Java_com_follow_clash_core_Core_setEventListener(JNIEnv *env, jobject thiz, jobject cb) {
}
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"
JNIEXPORT void JNICALL
Java_com_follow_clash_core_Core_suspended(JNIEnv *env, jobject thiz, jboolean suspended) {
}
extern "C"
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
+61
View File
@@ -0,0 +1,61 @@
#include "jni_helper.h"
#include <cstdlib>
#include <cstring>
static JavaVM *global_vm;
static jclass c_string;
static jmethodID m_new_string;
static jmethodID m_get_bytes;
void initialize_jni(JavaVM *vm, JNIEnv *env) {
global_vm = vm;
c_string = reinterpret_cast<jclass>(new_global(find_class("java/lang/String")));
m_new_string = find_method(c_string, "<init>", "([B)V");
m_get_bytes = find_method(c_string, "getBytes", "()[B");
}
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;
}
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));
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) {
if (global_vm->GetEnv(reinterpret_cast<void **>(&jni->env), JNI_VERSION_1_6) == JNI_OK) {
jni->require_release = 0;
return;
}
if (global_vm->AttachCurrentThread(&jni->env, nullptr) != JNI_OK) {
abort();
}
jni->require_release = 1;
}
void jni_detach_thread(const scoped_jni *env) {
if (env->require_release) {
global_vm->DetachCurrentThread();
}
}
void release_string(char **value) {
free(*value);
}
+34
View File
@@ -0,0 +1,34 @@
#pragma once
#include <jni.h>
struct scoped_jni {
JNIEnv *env;
int require_release;
};
extern void initialize_jni(JavaVM *vm, JNIEnv *env);
extern jstring jni_new_string(JNIEnv *env, const char *str);
extern char *jni_get_string(JNIEnv *env, jstring str);
extern void jni_attach_thread(scoped_jni *jni);
extern void jni_detach_thread(const scoped_jni *env);
extern void release_string(char **value);
#define ATTACH_JNI() __attribute__((unused, cleanup(jni_detach_thread))) \
scoped_jni _jni{}; \
jni_attach_thread(&_jni); \
JNIEnv *env = _jni.env
#define scoped_string __attribute__((cleanup(release_string))) char*
#define find_class(name) env->FindClass(name)
#define find_method(cls, name, signature) env->GetMethodID(cls, name, signature)
#define new_global(obj) env->NewGlobalRef(obj)
#define del_global(obj) env->DeleteGlobalRef(obj)
#define get_string(jstr) jni_get_string(env, jstr)
#define new_string(cstr) jni_new_string(env, cstr)
@@ -0,0 +1,136 @@
package com.follow.clash.core
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.URI
object Core {
private external fun startTun(
fd: Int,
cb: TunInterface,
stack: String,
address: String,
dns: String,
)
external fun forceGC()
external fun updateDNS(
dns: String,
)
private fun parseInetSocketAddress(address: String): InetSocketAddress {
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(
fd: Int,
protect: (Int) -> Boolean,
resolverProcess: (protocol: Int, source: InetSocketAddress, target: InetSocketAddress, uid: Int) -> String,
stack: String,
address: String,
dns: String,
) {
startTun(
fd,
object : TunInterface {
override fun protect(fd: Int) {
protect(fd)
}
override fun resolverProcess(
protocol: Int,
source: String,
target: String,
uid: Int,
): String {
return resolverProcess(
protocol,
parseInetSocketAddress(source),
parseInetSocketAddress(target),
uid,
)
}
},
stack,
address,
dns,
)
}
external fun suspended(
suspended: Boolean,
)
private external fun invokeMethod(
data: String,
cb: InvokeInterface,
)
fun invokeMethod(
data: String,
cb: (result: String?) -> Unit,
) {
invokeMethod(
data,
object : InvokeInterface {
override fun onResult(result: String?) {
cb(result)
}
},
)
}
private external fun setEventListener(cb: InvokeInterface?)
fun updateEventListener(
callback: ((result: String?) -> Unit)?,
) {
if (callback == null) {
setEventListener(null)
} else {
setEventListener(
object : InvokeInterface {
override fun onResult(result: String?) {
callback(result)
}
},
)
}
}
fun quickSetup(
initParamsString: String,
setupParamsString: String,
callback: (result: String?) -> Unit,
) {
quickSetup(
initParamsString,
setupParamsString,
object : InvokeInterface {
override fun onResult(result: String?) {
callback(result)
}
},
)
}
private external fun quickSetup(
initParamsString: String,
setupParamsString: String,
cb: InvokeInterface,
)
external fun stopTun()
external fun getTraffic(onlyStatisticsProxy: Boolean): String
external fun getTotalTraffic(onlyStatisticsProxy: Boolean): String
init {
System.loadLibrary("core")
}
}
@@ -0,0 +1,8 @@
package com.follow.clash.core
import androidx.annotation.Keep
@Keep
interface InvokeInterface {
fun onResult(result: String?)
}
@@ -0,0 +1,10 @@
package com.follow.clash.core
import androidx.annotation.Keep
@Keep
interface TunInterface {
fun protect(fd: Int)
fun resolverProcess(protocol: Int, source: String, target: String, uid: Int): String
}
+4 -3
View File
@@ -1,5 +1,6 @@
org.gradle.jvmargs=-Xmx4G
android.useAndroidX=true
android.enableJetifier=true
kotlin_version=1.9.22
agp_version=8.9.1
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false
+21
View File
@@ -0,0 +1,21 @@
[versions]
firebaseBom = "34.15.0"
minSdk = "23"
targetSdk = "36"
compileSdk = "36"
ndkVersion = "28.2.13676358"
coreKtx = "1.17.0"
annotationJvm = "1.9.1"
coreSplashscreen = "1.0.1"
gson = "2.13.1"
smaliDexlib2 = "3.0.9"
[libraries]
androidx-core = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
annotation-jvm = { module = "androidx.annotation:annotation-jvm", version.ref = "annotationJvm" }
core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" }
firebase-analytics = { module = "com.google.firebase:firebase-analytics" }
firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBom" }
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" }
+1 -1
View File
@@ -2,5 +2,5 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-all.zip
+1
View File
@@ -0,0 +1 @@
/build

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