Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fba3f3c8b1 | |||
| 4418517709 | |||
| e2ae3f387f | |||
| 444abb19fa | |||
| a4e22ce3e8 | |||
| 0ea30a2d97 | |||
| 0d02b62551 | |||
| d0d0d5a5fb | |||
| 879ed8870e | |||
| e40f5832ba | |||
| 72a886cb47 | |||
| e59d01ad51 | |||
| 9c0f8bbcd2 | |||
| 4db331bed0 | |||
| 2a2522bbc6 | |||
| 12403b063a | |||
| c7be7023d3 | |||
| 7c61c90ac2 | |||
| c5bf5bd487 | |||
| 60f371afd8 | |||
| db03880579 | |||
| d0b1562e1d | |||
| 26cfbaf456 | |||
| aaf934c2d1 | |||
| c0fcbc03ee | |||
| ae29f38f6b | |||
| adf715f8ac | |||
| c6eaa0a68b | |||
| 96fcb9ab5e | |||
| 62addf738a | |||
| e2f678909d | |||
| 7fb4f4f711 | |||
| 903e2b8879 | |||
| 2df430ca31 | |||
| aee7ee2643 | |||
| 9a99897864 | |||
| 4551e57ff4 | |||
| 1cffeaa29e | |||
| 5f2c12642b | |||
| c493df386d | |||
| 425d32b50d | |||
| 51e24ef9de | |||
| 7c831855ef | |||
| 7e7f1f89f3 | |||
| 5179d55dfb | |||
| ac2f6b919e | |||
| 45015f856b |
@@ -0,0 +1,32 @@
|
||||
# Agent Configuration Model
|
||||
|
||||
Use the smallest durable surface that matches the job.
|
||||
|
||||
## Surfaces
|
||||
|
||||
- `AGENTS.md`: repository entry point. Keep it small and reserve it for always-on rules, routing, and high-priority expectations.
|
||||
- No `CLAUDE.md`: Claude Code v2.1.277+ loads `AGENTS.md` at startup only while no `CLAUDE.md`, `.claude/CLAUDE.md`, or `CLAUDE.local.md` sits in the working directory or above it, so adding one silently replaces `AGENTS.md` for Claude. Sessions that cannot load `AGENTS.md` directly (third-party providers, telemetry disabled) need an uncommitted `CLAUDE.local.md` containing `@AGENTS.md`. The one nested `CLAUDE.md`, in `plugins/rust_api/`, imports the root `AGENTS.md` for the same reason.
|
||||
- `.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 skills shared by Codex and Claude Code. Use these for repeatable workflows that should be discoverable by name and description; `.claude/skills/` holds one symlink per skill so Claude Code finds the same files.
|
||||
- `.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. None exist yet. 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.
|
||||
- Split a rule into the part that can be counted and the part that cannot, and enforce them differently. A rule resting entirely on judgment gets decided at the keyboard, at the moment the exception looks most justified; the comment policy is the worked example, where "is this comment useful" stays prose and review while "are these comments too frequent" became a measured gate. Find the countable half before writing more prose.
|
||||
- Restate the one-line form of a rule in every context that must obey it, and keep its explanation in one `.agents/*.md` file that the others link to. Prefer a runnable check to either: a gate an agent trips before reporting done outperforms a paragraph asking it to have been careful.
|
||||
- State a gate's real force. Enforcement differs per tool, and a rule that claims uniform coverage it does not have is worse than one that names the gap.
|
||||
- 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.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,320 @@
|
||||
# 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
|
||||
```
|
||||
|
||||
The Go core and the Rust helper build automatically: Flutter runs
|
||||
`plugins/setup/hook/build.dart` on every `flutter build` and `flutter test`,
|
||||
and the hook drives `CoreBuilder` from the `setup_hooks` package in
|
||||
`plugins/setup/setup_hooks/`. Android builds take the NDK from the C compiler
|
||||
Flutter hands the hook; no `ANDROID_NDK` variable is needed.
|
||||
Artifacts land in `libclash/`. The hook reruns only when Go, Rust, or
|
||||
`setup_hooks` inputs change; the fingerprint cache lives in
|
||||
`.dart_tool/setup_build_cache/`. To force a rebuild, delete that directory:
|
||||
|
||||
```bash
|
||||
rm -rf .dart_tool/setup_build_cache
|
||||
```
|
||||
|
||||
Flutter hides the hook's output on success, so to see why the Core was or was
|
||||
not rebuilt read `.dart_tool/setup_build_cache/hook.log`; each invocation starts
|
||||
with a `===` line carrying its timestamp and target.
|
||||
|
||||
The hook runs for `flutter test` and for `dart run` of any root-package script
|
||||
too, so a pure Dart test run needs the Go toolchain, and on Linux and Windows
|
||||
also `cargo` and `rustc` — the Helper fingerprint shells out to both even on a
|
||||
cache hit. The hook protocol carries no build mode and no caller, so the only
|
||||
switch is the user-define in the root `pubspec.yaml`:
|
||||
|
||||
```yaml
|
||||
hooks:
|
||||
user_defines:
|
||||
setup:
|
||||
build_assets: true # false turns the Go core and Helper hook into a no-op
|
||||
rust_api:
|
||||
build_assets: true # false does the same for the Rust library
|
||||
```
|
||||
|
||||
CI flips `setup` to `false` with `yq` before `dart run tool/changelog.dart` and
|
||||
`flutter test`, and restores the file afterwards; no test loads the Go core. `rust_api`
|
||||
stays on there because the editor tests in `test/plugins/code_forge/` load the Rust
|
||||
library the hook builds; the release-notes job, which runs no tests, flips both.
|
||||
Never commit `false`: a build with it set stages whatever `libclash/` already
|
||||
holds and bundles no Rust library, which is why `setup.dart` refuses to package
|
||||
while it is set. Locally, `false` is worth setting for a Dart-only test loop,
|
||||
but the hook cache keys on the user-define, so the first build afterwards runs
|
||||
the hooks again. The editor tests still need the Rust library: keep `rust_api`
|
||||
on, or point `FRB_DART_LOAD_EXTERNAL_LIBRARY_NATIVE_LIB_DIR` at a directory that
|
||||
holds a `rust_api` library built from the same sources.
|
||||
|
||||
## Flutter Development
|
||||
|
||||
Use the default Flutter SDK directly:
|
||||
|
||||
```bash
|
||||
flutter pub get
|
||||
flutter run
|
||||
flutter test
|
||||
```
|
||||
|
||||
Use `flutter test`, not `dart test`, because models pull in Flutter types.
|
||||
|
||||
A development build takes over the host exactly like a release: it creates the TUN device, sets the system proxy, on
|
||||
macOS rewrites the system DNS, registers itself as the login item, and on Windows and Linux starts its Core through the
|
||||
shared privileged Helper, which stops the Core an installed FlClash is running. Only the data directory is kept apart,
|
||||
and only for some builds: a macOS debug build is `com.follow.clash.debug` and an Android debug build `com.follow.clash.dev`,
|
||||
while profile and release builds, and every build on Windows and Linux, share the directory and preferences of the
|
||||
installed app. Safe mode keeps all of the host changes off and never opens the Core's listeners or external
|
||||
controller, so it does not compete with the installed app for the mixed, DNS, RESTful API and other loopback ports:
|
||||
|
||||
```bash
|
||||
flutter run --dart-define-from-file=env.json --dart-define=SAFE_MODE=true
|
||||
```
|
||||
|
||||
`.run/main_safe_mode.run.xml` is the same launch for IntelliJ, and `Flutter App (Safe Mode)` in `.zed/debug.json` (debugger) or `flutter run (Safe Mode)` in `.zed/tasks.json`
|
||||
(plain run) for Zed. The build is bannered `SAFE MODE`, the tray shows the
|
||||
`status_4` bug icon with a menu of only show and exit and no traffic title, and the settings keep their values, so
|
||||
nothing has to be flipped back before a normal run. See the safe mode section of `architecture.md` for the owners that consult the flag.
|
||||
|
||||
## 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`.
|
||||
|
||||
The Dart-Rust bindings in `plugins/rust_api` are generated by `flutter_rust_bridge_codegen` from
|
||||
`plugins/rust_api/flutter_rust_bridge.yaml`; rerun it from that directory after changing anything under
|
||||
`rust/src/api/`:
|
||||
|
||||
```bash
|
||||
cd plugins/rust_api && flutter_rust_bridge_codegen generate
|
||||
```
|
||||
|
||||
Generated output paths, configured in `build.yaml`:
|
||||
|
||||
- `lib/models/generated/*.g.dart`, `*.freezed.dart`.
|
||||
- `lib/providers/generated/*.g.dart`.
|
||||
- `lib/database/generated/*.g.dart`.
|
||||
|
||||
Tray and Windows app icons are generated, not hand-edited. `assets_source/images/icon/*.svg` is
|
||||
the source of truth; the script needs `rsvg-convert` (librsvg) on `PATH`:
|
||||
|
||||
```bash
|
||||
dart run tool/generate_status_icons.dart
|
||||
```
|
||||
|
||||
It writes the tray PNGs with Flutter `2.0x/`–`4.0x/` resolution variants to `assets/images/tray/unix/`,
|
||||
multi-size tray `.ico` files to `assets/images/tray/windows/`, and `windows/runner/resources/app_icon.ico`
|
||||
from `assets/images/icon.png`. macOS draws its menu bar icon as a template, so it skips the ringed color
|
||||
icons, whose logo shrinks to a small glyph once only alpha shows: `status_1` renders the bare logo from
|
||||
`assets_source/images/icon/glyph.svg` and `status_4` reuses `status_4.svg`, both recolored to black into
|
||||
`assets/images/tray/macos/`; the sources keep their colors. `glyph.svg` is also the geometry of the Android notification drawables
|
||||
`android/service/src/main/res/drawable/ic.xml` and `ic_service.xml`; change them together. `pubspec.yaml` declares the three tray directories with `platforms:` so each
|
||||
build only bundles the format its tray loads; a new status icon needs a source SVG and an entry in the
|
||||
script's `statusIconNames`, nothing in `pubspec.yaml`.
|
||||
|
||||
## 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/core/desktop/
|
||||
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
|
||||
```
|
||||
|
||||
`plugins/setup/setup_hooks/` is a pure Dart package and is tested with `dart test` from its own directory, which is
|
||||
also what CI runs; `tool/check_plugins.sh` does not descend into it. It must stay out of `flutter test`, because a test
|
||||
run of `plugins/setup` would execute the build hook itself.
|
||||
|
||||
Native plugin code under `plugins/*/{linux,macos,windows}` is compiled by the CI `desktop` job, which runs
|
||||
`flutter build <platform> --debug` on every push; `tool/check_plugins.sh` only analyzes and tests the Dart side.
|
||||
|
||||
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, or run `bash tool/check_plugins.sh` to analyze and test every plugin package the way CI does. Native plugin tests under platform folders are not run by `flutter test`.
|
||||
|
||||
For the current Core/service architecture, useful focused checks are:
|
||||
|
||||
```bash
|
||||
flutter test test/core/desktop/
|
||||
flutter test test/core/service_test.dart
|
||||
flutter test test/core/protocol_contract_test.dart
|
||||
flutter test test/manager/core_manager_test.dart
|
||||
flutter test test/providers/action_test.dart test/providers/system_action_test.dart
|
||||
flutter test test/widgets/core_status_button_test.dart
|
||||
```
|
||||
|
||||
What those suites own:
|
||||
|
||||
- `test/core/desktop/`: replaceable IPC transport, RPC request correlation/failure, direct/Helper process leases, and
|
||||
latest-intent desktop lifecycle convergence.
|
||||
- `test/core/service_test.dart`: `CoreService` composition and terminal close behavior.
|
||||
- `test/core/protocol_contract_test.dart`: shared Dart/Go method and event-envelope compatibility, including event batches.
|
||||
- `test/providers/action_test.dart`: Core start/restart orchestration and overlapping restart requests.
|
||||
- `test/providers/system_action_test.dart`: ordered, idempotent exit cleanup and watchdog behavior.
|
||||
- `test/widgets/core_status_button_test.dart`: 600-millisecond connecting presentation hold, immediate failure display,
|
||||
long-running connecting state, and disconnected restart.
|
||||
|
||||
## Native Component Verification
|
||||
|
||||
The CI Go-wrapper checks can be reproduced without CGO:
|
||||
|
||||
```bash
|
||||
cd core
|
||||
CGO_ENABLED=0 go test .
|
||||
CGO_ENABLED=0 go vet .
|
||||
```
|
||||
|
||||
The Windows Helper's loopback/session protocol tests are host-independent by default. Windows CI additionally enables its
|
||||
service implementation:
|
||||
|
||||
```bash
|
||||
cargo fmt --manifest-path services/helper/Cargo.toml -- --check
|
||||
cargo test --manifest-path services/helper/Cargo.toml
|
||||
cargo test --manifest-path services/helper/Cargo.toml --features windows-service
|
||||
```
|
||||
|
||||
The last command requires Windows for meaningful service coverage. Native Android lifecycle edits should at minimum
|
||||
compile the modules they touch, with the JDK 17 that CI installs. On macOS the Homebrew `openjdk@17` keg is not
|
||||
registered with `java_home`, so export it by prefix:
|
||||
|
||||
```bash
|
||||
cd android
|
||||
export JAVA_HOME="$(brew --prefix openjdk@17)/libexec/openjdk.jdk/Contents/Home" # elsewhere: any JDK 17
|
||||
./gradlew :service:compileDebugKotlin
|
||||
./gradlew :app:compileDebugKotlin
|
||||
```
|
||||
|
||||
Always-on VPN entry, system VPN revoke, actual permission UI, and rapid device start/stop still require Android device or
|
||||
emulator validation; Kotlin compilation cannot prove those system callbacks.
|
||||
|
||||
## Changelog And Release
|
||||
|
||||
The changelog is derived from Conventional Commits by `tool/changelog.dart` and written to two committed files:
|
||||
`CHANGELOG.md` for readers and `changelog.json` for the renderers. See `.agents/rules.md` for the `Changelog:` trailers
|
||||
that decide the wording.
|
||||
|
||||
The app ships no changelog of its own. `render release` appends the released version as JSON inside an HTML comment
|
||||
(`<!-- flclash:changelog:json … -->`), so the release body GitHub already returns to `checkForUpdate` carries the
|
||||
notes shown in the update dialog. `parseReleaseChangelog` reads that block and falls back to the English
|
||||
bullets when a release predates it.
|
||||
|
||||
```bash
|
||||
dart run tool/changelog.dart verify # what CI checks
|
||||
dart run tool/changelog.dart release --version 0.8.96
|
||||
dart run tool/changelog.dart build --unreleased # changelog.json only, includes untagged work
|
||||
dart run tool/changelog.dart render release --out release.md
|
||||
dart run tool/changelog.dart render telegram --out telegram.md
|
||||
```
|
||||
|
||||
Releasing a stable version, in order:
|
||||
|
||||
```bash
|
||||
tool/bump_version.sh all
|
||||
dart run tool/changelog.dart release --version 0.8.96
|
||||
git commit -am "chore(release): v0.8.96"
|
||||
git tag v0.8.96
|
||||
git push origin main && git push origin v0.8.96
|
||||
```
|
||||
|
||||
Push the tag by name. Every release tag here is lightweight, and `--follow-tags` carries annotated tags only: it skips a
|
||||
lightweight one silently, so the branch lands, the tag does not, and the release workflow never fires.
|
||||
|
||||
`tool/release.sh` drives both paths so the ordering below cannot be got wrong by hand. It resolves the version (bumping
|
||||
the patch when pubspec still names an already tagged one), prints the notes the tag would ship, and only pushes with
|
||||
`--push`:
|
||||
|
||||
```bash
|
||||
tool/release.sh pre --dry-run # plan and notes, changes nothing
|
||||
tool/release.sh pre --push # bump, tag vX.Y.Z-pre.N, push
|
||||
tool/release.sh stable --push # changelog, chore(release) commit, tag, push
|
||||
```
|
||||
|
||||
The release commit comes before the tag on purpose: the generated wording is reviewable in the diff before it ships, and
|
||||
the tag is what `render release` reads. CI never writes back to the repository; it only runs `verify`. Wording in
|
||||
`changelog.json` may be edited by hand as long as no derivable entry disappears and every entry still points at a commit
|
||||
inside that version's range.
|
||||
|
||||
Entries at or below `v0.8.96` are frozen: they predate the pipeline, live under the `<!-- changelog:frozen -->` marker
|
||||
in `CHANGELOG.md`, and are never regenerated.
|
||||
|
||||
`verify` compares a version only when its tag is reachable from `HEAD`, because that is the same scope the builder walks
|
||||
(`git tag --merged`). A branch cut before the newest release cannot derive that version at all, so `verify` names it as
|
||||
skipped and moves on instead of reporting drift that does not exist. Checking mere tag existence is what made every such
|
||||
branch fail on an unrelated release.
|
||||
|
||||
Prerelease tags (`v0.8.96-pre.N`) skip the release commit, and CI renders their notes with `build --unreleased` for the
|
||||
Telegram post. They publish no GitHub release, so the update dialog never sees them. `build --unreleased` reads the
|
||||
version from `pubspec.yaml` rather than the tag, so the patch has to be bumped before the first `-pre.N` of a cycle:
|
||||
while `v<pubspec version>` is still tagged it refuses to collect anything and the release job fails.
|
||||
|
||||
## Verify
|
||||
|
||||
In the public repository every branch push runs the `dart` job; in the private
|
||||
`origin` every check job runs only when started by hand (`workflow_dispatch`).
|
||||
The `dart` job performs these root-package checks in order:
|
||||
|
||||
```bash
|
||||
bash tool/check_commit_msg_test.sh
|
||||
bash tool/check_comment_density_test.sh
|
||||
flutter pub get
|
||||
dart format --output=none --set-exit-if-changed lib test tool plugins setup.dart
|
||||
flutter analyze --no-fatal-infos
|
||||
dart run tool/changelog.dart verify # main and tags only
|
||||
flutter test --reporter expanded --coverage
|
||||
dart run tool/check_coverage.dart coverage/lcov.info 75
|
||||
```
|
||||
|
||||
Run `flutter analyze` before committing; CI fails on anything it reports.
|
||||
|
||||
Release builds run only for `v*` tag pushes; pull requests trigger nothing.
|
||||
Root analysis excludes `plugins/**`, and root tests do not discover nested
|
||||
plugin packages, so parallel jobs validate the rest from their own package
|
||||
directories: `plugins` (local Flutter packages and the setup build tool), `go`
|
||||
(the Core wrapper, plus an NDK-backed vet of the Android files), `android`
|
||||
(JVM unit tests for `:common`, `:service` and `:app`, with the Flutter compile
|
||||
tasks excluded so no native build hook runs), `rust` (both crates), and a
|
||||
Windows runner for the helper's `windows-service` feature. A `desktop` job also
|
||||
compiles a debug build per desktop platform. Release builds run only in the
|
||||
public repository and start once every job except `desktop` passes.
|
||||
|
||||
`bash tool/check_plugins.sh` is that plugin gate, and CI runs the same script.
|
||||
It discovers every `plugins/*/pubspec.yaml`, analyzes each package, and runs
|
||||
`flutter test` wherever `test/*_test.dart` exists. Adding a plugin package needs
|
||||
no workflow edit; enumerating packages by hand in the workflow is what
|
||||
previously left `plugins/tray` unanalyzed and untested.
|
||||
|
||||
## Worktree Tooling
|
||||
|
||||
```bash
|
||||
bash tool/worktrees.sh list # every worktree with owner tool and dirty/clean state
|
||||
bash tool/worktrees.sh prune # remove clean worktrees; add --force to drop dirty ones too
|
||||
```
|
||||
@@ -0,0 +1,73 @@
|
||||
# Flutter Upgrade Checklist
|
||||
|
||||
Read this before changing `FLUTTER_VERSION` in `.github/workflows/build.yaml` or `.fvmrc`. Each entry is a
|
||||
workaround tied to a Flutter bug: what it does, where it lives, and what has to be true before it can go. Remove an
|
||||
entry together with its workaround; add one whenever a new workaround is pinned to the SDK.
|
||||
|
||||
## Stable cadence
|
||||
|
||||
Flutter ships a stable release about once a quarter, three minor versions apart (3.41 in February, 3.44 in May, 3.47
|
||||
in August 2026). The two minors in between (3.48, 3.49) are beta only, so a fix that lands on `main` reaches stable
|
||||
in the next multiple, 3.50, expected around mid-November 2026. Hotfix releases (3.47.x) carry only cherry-picks
|
||||
listed in the SDK's `CHANGELOG.md`.
|
||||
|
||||
## Impeller disabled on Intel macOS release builds
|
||||
|
||||
- **Bug:** [flutter/flutter#191538](https://github.com/flutter/flutter/issues/191538). From 3.47, when Impeller
|
||||
became the default desktop renderer, Intel (x86_64) Macs flicker across the whole window while scrolling or
|
||||
animating, and text can render garbled. Apple Silicon is not affected.
|
||||
- **Fix upstream:** landed on `main` before 2026-08-25 and verified on `3.48.0-1.0.pre-404`; it is not in any
|
||||
3.47.x hotfix (checked through 3.47.5).
|
||||
- **Workaround:** the release job for `macos-15-intel` (`arch: amd64`) runs
|
||||
`plutil -replace FLTEnableImpeller -bool NO macos/Runner/Info.plist` before `dart setup.dart`, so only the Intel
|
||||
DMG falls back to Skia. The checked-in `Info.plist` does not set the key, so local builds and the arm64 DMG keep
|
||||
Impeller. The macOS embedder reads the key at launch in `FlutterDartProject.mm` (`enableImpeller`), release
|
||||
builds included.
|
||||
- **Side effect:** Skia on Intel renders blur, shadows and text slightly differently; judge Intel-only visual reports
|
||||
against Skia, not Impeller.
|
||||
- **Remove when:** `FLUTTER_VERSION` moves to a stable that contains the fix (3.50 or later, or a 3.47.x whose
|
||||
changelog lists #191538). Before dropping the step, build the Intel DMG without it and scroll the proxies and
|
||||
dashboard pages on an Intel Mac with its integrated GPU driving the built-in display; an external display can
|
||||
switch to the discrete GPU and hide the flicker.
|
||||
|
||||
## Windows view visibility forwarded by the runner
|
||||
|
||||
- **Bug:** the Windows embedder's `WindowsLifecycleManager` counts the Flutter view as a window of its own and keeps
|
||||
it visible until the view is sized to zero. Hiding the top-level window never resizes the view, and the `window`
|
||||
plugin keeps it sized while minimized, so the app was never told `hidden`: animations kept rendering in the tray
|
||||
and `appVisibleProvider` stayed true.
|
||||
- **Workaround:** `WindowPluginForwardVisibility` in the `window` plugin's
|
||||
`windows/include/window/window_plugin_runner.h` hands the top-level window's `WM_SHOWWINDOW` and `WM_SIZE` to
|
||||
`ProcessExternalWindowMessage` for the view's HWND, so the view shows and hides with its window.
|
||||
`FlutterWindow::MessageHandler` in `windows/runner/flutter_window.cpp` calls it before `HandleTopLevelWindowProc`;
|
||||
the Flutter runner template has no such call, so keep it when regenerating the runner.
|
||||
- **Remove when:** the engine hides the view together with its top-level window. Check with the proxy running and
|
||||
the window focused: hiding it to the tray and minimizing it must each log `AppLifecycleState.hidden`.
|
||||
|
||||
## macOS activation resumes a hidden window
|
||||
|
||||
- **Bug:** `handleWillBecomeActive` in the macOS engine's `FlutterEngine.mm` resumes when any window in
|
||||
`NSApp.windows` is visible, and the tray's `NSStatusBarWindow` always is. Activating the app while its window was
|
||||
minimized or hidden, such as Cmd-Tab back to a minimized window, reported `resumed`, and the next deactivation
|
||||
`inactive`, so frames ran until the window was shown again.
|
||||
- **Workaround:** the `window` plugin observes will-become-active and will-resign-active and, once the engine has
|
||||
answered, sends `AppLifecycleState.hidden` on `flutter/lifecycle` while its window is not visible
|
||||
(`applicationActivityChanged` in `WindowPlugin.swift`). A window brought back without another activation change
|
||||
would then wait on an occlusion notification macOS does not always post, keeping its last frame, so `show` and a
|
||||
deminiaturize hand the state back (`windowBecameVisible`).
|
||||
- **Remove when:** the engine ignores status item windows on activation. Check without the workaround: minimize the
|
||||
window, switch to another app, then Cmd-Tab back without restoring it; the log must stay at
|
||||
`AppLifecycleState.hidden`.
|
||||
|
||||
## Linux minimize on Wayland reads as inactive
|
||||
|
||||
- **Bug:** the Linux engine reports `hidden` for `GDK_WINDOW_STATE_ICONIFIED`, but xdg-shell has no minimized state,
|
||||
so GTK 3 never sets it on Wayland. A minimized window reported only its focus loss, as `inactive`, and kept
|
||||
rendering at the display rate (GNOME 46).
|
||||
- **Workaround:** the `window` plugin's Linux `minimize` records the time, and a focus loss within 500 ms of it sends
|
||||
`AppLifecycleState.hidden`. Its `window-state-event` handler is connected after the engine's, so that message
|
||||
follows the engine's `inactive`; the compositor focuses the window when it restores it, which the engine reports as
|
||||
`resumed`. A minimize the compositor starts itself (Super+H, the dock) is not covered.
|
||||
- **Remove when:** the Linux engine reports minimized windows on Wayland, which needs GTK 4 or xdg-shell's
|
||||
`suspended` state. Check in a GNOME Wayland session without the workaround: minimizing from the caption button
|
||||
must log `AppLifecycleState.hidden`.
|
||||
@@ -0,0 +1,100 @@
|
||||
# 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
|
||||
|
||||
- Flutter stays on the 3.47.x line. Release CI pins 3.47.4 and is the source
|
||||
of truth for release builds; `.fvmrc` and local SDKs may sit on another 3.47
|
||||
patch. The editor in `plugins/code_forge` came from `code_forge` 10.14.0, which
|
||||
needs Dart 3.13.2, so the CI pin cannot go below Flutter 3.47.2.
|
||||
- Before moving the pin, walk `flutter-upgrade.md`: it lists the workarounds tied to
|
||||
the current SDK, such as Impeller disabled on the Intel macOS release build.
|
||||
- The Dart SDK lower bound in `pubspec.yaml` sets the package language
|
||||
version. `flutter analyze` excludes `lib/**/generated/**`, so a raise is
|
||||
proven by `flutter test`, which parses the generated code, not by analyze.
|
||||
|
||||
## Forked Dependencies
|
||||
|
||||
Two `pubspec.yaml` dependencies are pinned to a fork by commit SHA. Both forks
|
||||
live under `chen08209`, the same account that owns this repository, so they are
|
||||
maintained in-house rather than tracked from a third party: advancing a pin is a
|
||||
local decision, and there is no external maintainer to wait on for the patch
|
||||
itself. What each fork still waits on is the *upstream* fix that would let the pin
|
||||
be dropped entirely, recorded below.
|
||||
|
||||
Each entry records what the fork changes and what has to be true before it can go
|
||||
back to the published package, so a future upgrade does not have to rediscover it.
|
||||
Re-verify a fork by diffing its pub cache checkout against the published version
|
||||
of the same number:
|
||||
|
||||
```bash
|
||||
diff -ru ~/.pub-cache/hosted/pub.dev/<name>-<version> ~/.pub-cache/git/<name>-<sha>
|
||||
```
|
||||
|
||||
The `window_manager` fork (`chen08209/window_manager`, tag `v0.5.1-flclash.3`)
|
||||
was replaced by the in-repo `plugins/window` package; the behaviors it carried
|
||||
(work-area maximize with a hidden title bar on Windows, geometry restore on
|
||||
re-map on Linux, corner preference, terminate and activate events) live there
|
||||
now, see the window plugin section of `architecture.md`.
|
||||
|
||||
`launch_at_startup` — `chen08209/launch_at_startup`, version 0.5.1.
|
||||
|
||||
- Migrates `win32_registry` from `^2.0.0` to `^3.0.3`, which is a breaking rename
|
||||
across the whole Windows implementation (`Registry.openPath` → `CURRENT_USER.open`,
|
||||
`createValue` → `setValue`, `getStringValue` → `getString`).
|
||||
- This one is not optional while it lasts: FlClash depends on `win32_registry: ^3.0.3`
|
||||
directly, and upstream's `^2.0.0` constraint cannot co-resolve with it.
|
||||
- Drop the fork when upstream publishes a release that accepts `win32_registry` 3.x.
|
||||
|
||||
`yaml_writer` — `chen08209/yaml_writer`, version 2.1.0.
|
||||
|
||||
- Adds `StringNode.quoteKey()` and applies it to map keys in `lib/src/node.dart`.
|
||||
Upstream quotes values but emits keys verbatim, so a profile key needing quotes
|
||||
is written as invalid YAML.
|
||||
- Drop the fork once upstream quotes map keys by the same
|
||||
`isValidUnquotedString` rule it already applies to values.
|
||||
|
||||
The `code_forge` fork (`chen08209/code_forge`) is frozen: its Dart code now lives
|
||||
in the in-repo `plugins/code_forge` package and its Rust code in the `editor/`
|
||||
module of `plugins/rust_api`, both trimmed to what FlClash uses.
|
||||
`plugins/code_forge/README.md` records what was kept and what was removed;
|
||||
upstream's MIT notice stays in `plugins/code_forge/LICENSE`.
|
||||
|
||||
## Dependency Ceilings
|
||||
|
||||
`flutter pub outdated` lists only upstream-held entries, so treat them as
|
||||
resolved-until-the-ceiling-moves rather than as debt:
|
||||
|
||||
- `intl` is intentionally unbounded (`any`) and `material_color_utilities` is
|
||||
resolved by the Flutter SDK; neither is a bound this repository sets.
|
||||
|
||||
`freezed`, the `riverpod` set, `build_runner`, `drift_dev`, `intl_utils` and
|
||||
`test` all resolve against the analyzer the pinned SDK ships; none of them is
|
||||
held back. When an upgrade fails version solving, record the blocker here with
|
||||
what has to change upstream before the pin can move, so the next
|
||||
`flutter pub outdated` run does not rediscover it.
|
||||
|
||||
`_initDynamicColor` in `lib/bootstrap.dart` reads the `dynamic_color` method
|
||||
channel directly and decodes the palette list into a `TonalPalette`, instead of
|
||||
going through `DynamicColorPlugin.getCorePalette()` and its deprecated
|
||||
`CorePalette` return type. Everything downstream sees `DynamicColorSeeds`, so a
|
||||
non-deprecated plugin accessor changes only that one function.
|
||||
|
||||
## Build Dependencies
|
||||
|
||||
Linux:
|
||||
|
||||
```bash
|
||||
sudo apt-get install libayatana-appindicator3-dev
|
||||
```
|
||||
|
||||
Windows:
|
||||
|
||||
- GCC and Inno Setup.
|
||||
|
||||
macOS:
|
||||
|
||||
```bash
|
||||
npm install -g appdmg
|
||||
```
|
||||
@@ -0,0 +1,536 @@
|
||||
# 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
|
||||
|
||||
The lint set lives in `lint_options.yaml` at the repo root. The root `analysis_options.yaml` and every local plugin under
|
||||
`plugins/*` include it, so application and plugin code are held to the same rules. Add or change a rule there, not in an
|
||||
individual `analysis_options.yaml`; those files carry only their own `analyzer.exclude` entries.
|
||||
|
||||
`lint_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`.
|
||||
- `only_throw_errors: true`: throw an `Exception` or `Error`, never a bare `String`.
|
||||
|
||||
Failures whose whole content is a message meant for the user throw
|
||||
`MessageException` from `lib/common/exception.dart`. Its `toString()` is the bare
|
||||
message, which is what `globalState.safeRun` surfaces in the dialog, so the
|
||||
user-facing text is unchanged from the older `throw someMessage` idiom while the
|
||||
throw stays catchable as an `Exception` and carries a stack trace. Assert on it
|
||||
with `isA<MessageException>().having((e) => e.message, 'message', ...)`, not on a
|
||||
raw string.
|
||||
|
||||
### Corner Radius
|
||||
|
||||
Rounded corners are superellipses (continuous corners) everywhere, never circular arcs; when a component offers
|
||||
both, pick the superellipse. `test/lint/superellipse_corners_test.dart` fails on the circular APIs below in `lib/`. Use
|
||||
the superellipse API at each layer:
|
||||
|
||||
- Shapes: `RoundedSuperellipseBorder` instead of `RoundedRectangleBorder`.
|
||||
- Clips: `ClipRSuperellipse` instead of `ClipRRect`.
|
||||
- Container decorations: `ShapeDecoration(shape: RoundedSuperellipseBorder(...))` instead of
|
||||
`BoxDecoration(borderRadius: ...)`; borders move to the shape's `side`, and a `Container` with
|
||||
`clipBehavior` still clips to the shape path.
|
||||
- Canvas: `canvas.drawRSuperellipse(RSuperellipse.fromRectAndRadius(...))` instead of `drawRRect`.
|
||||
- Ink: `InkWell.customBorder` with an `AppShape` instead of `InkWell.borderRadius`.
|
||||
|
||||
Passing `BorderRadius.circular(x)` as the `borderRadius` argument of these APIs is expected — it only
|
||||
carries the corner magnitude; the rendered geometry stays a superellipse.
|
||||
|
||||
Pills are no exception. `StadiumBorder` joins its round caps to the straight sides with a curvature jump, while
|
||||
`AppShape.full`, a superellipse at full radius, blends them, and on a square it renders the exact circle, so it serves
|
||||
circles too. Material 3 defaults every button, icon button, segmented button, and search bar to `StadiumBorder`;
|
||||
`ThemeData.withAppShapes` replaces that for the button themes, and a local `FilledButtonTheme` or `IconButtonTheme`
|
||||
replaces the app's, so merge the ambient `XButtonTheme.of(context).style` into it, as `CommonMinFilledButtonTheme`
|
||||
does. `CircleBorder` and `BoxShape.circle` are fine for something that is always square.
|
||||
|
||||
APIs that accept only a `Radius`, such as `ScrollbarThemeData.radius`, keep circular corners; supply the superellipse
|
||||
with an enclosing clip or shape where it shows.
|
||||
|
||||
CI gates formatting: `dart format --output=none --set-exit-if-changed lib test
|
||||
tool plugins setup.dart` runs before `flutter analyze`.
|
||||
|
||||
These directories are excluded from analysis:
|
||||
|
||||
- `build/**`
|
||||
- `lib/l10n/intl/**`
|
||||
- `lib/**/generated/**`
|
||||
- `plugins/**`
|
||||
- the platform runner directories: `android/`, `ios/`, `web/`, `windows/`, `macos/`, `linux/`
|
||||
|
||||
## Comments
|
||||
|
||||
Comments are part of the code and some of them are load-bearing. What this repository limits is the useless and the
|
||||
frequent. Every comment that restates the code devalues the ones carrying real information, until readers skim past all
|
||||
of them — a file with three comments that matter is more readable than one with thirty.
|
||||
|
||||
The two halves are enforced differently, because only one of them can be counted. Whether a comment is useless is a
|
||||
judgment, and it lives in the rules below and in review. Whether comments are too frequent is arithmetic, and
|
||||
`comment-density` measures it.
|
||||
|
||||
### What a Comment Must Earn
|
||||
|
||||
- Write one when it carries something the code cannot: a non-obvious constraint, an upstream or platform behavior being
|
||||
worked around, a reason the next reader would otherwise get wrong, a coupling to a value defined somewhere else.
|
||||
- Never restate what the code already says. If the comment and the line below it convey the same thing, the comment is
|
||||
noise.
|
||||
- Never narrate. Not what this diff changed, not what the code used to be, not the order you did things in. Version
|
||||
history belongs in git and change rationale belongs in the commit message.
|
||||
- Never annotate line by line or statement by statement. A block that seems to need a comment per step needs better
|
||||
names or a smaller decomposition instead — reach for structure before prose.
|
||||
- Prefer encoding the intent in structure and naming. A named mixin, type, or method that makes the invariant hard to
|
||||
break beats a paragraph asking the next reader not to break it.
|
||||
- Delete commented-out code, stale version notes, and comments that only restate the code, whenever you edit the file
|
||||
that contains them. This does not need approval.
|
||||
- These are not comments and are exempt from every rule here, including the density count: analyzer and linter
|
||||
directives (`// ignore:`, `// ignore_for_file:`, `// coverage:ignore`), license and copyright headers,
|
||||
code-generation markers, and comments inside vendored upstream code such as `lib/widgets/open_container.dart`.
|
||||
|
||||
### Density
|
||||
|
||||
`tool/check_comment_density.sh` fails a file whose added lines are more than 5% standalone comment lines, ignoring
|
||||
diffs under 20 added lines so small edits are never caught. The number is calibrated on this repository's own history:
|
||||
healthy changes sit at or under 3.6%, while the core fix that prompted the gate ran 22.4%.
|
||||
|
||||
It is a ceiling on frequency, not a target to fill. Do not read `core/`'s inherited mihomo density as a quota either —
|
||||
it is forked upstream code, not a house style.
|
||||
|
||||
Three gates run the same script, and they do not have equal force. The `PostToolUse` hooks in `.claude/settings.json`
|
||||
and `.codex/config.toml` run after the tool and hand offending lines back as feedback; neither can undo the completed
|
||||
write. The `comment-density` pre-commit hook fails the commit, and it is the only hard gate that covers every tool.
|
||||
Behavior is pinned by `tool/check_comment_density_test.sh`, which CI runs directly.
|
||||
|
||||
When a change genuinely warrants more, raise the ceiling for that run rather than working around it:
|
||||
`COMMENT_DENSITY_MAX=20 git commit`, or `SKIP=comment-density git commit` to step past it entirely.
|
||||
|
||||
### Where Knowledge Belongs
|
||||
|
||||
A comment is one of three destinations, and often the weakest. Pick by where the constraint would be violated, not by
|
||||
how important it feels.
|
||||
|
||||
- **Assertable behavior goes in a test.** A test is the only form that cannot drift, because it fails when the behavior
|
||||
it describes is broken. Prefer it over both a comment and a document whenever the fact can be checked in code.
|
||||
- **Repository-wide defaults, ownership, and invariants go in `.agents/*.md` or a `.agents/skills/*/SKILL.md`.** They
|
||||
are violated from many files, so they must reach every future agent at session start. A comment in one file cannot do
|
||||
that.
|
||||
- **A fact that is true only at one call site, and is not visible from that call site, is the one thing a comment does
|
||||
better than a test or a document.** Its value is being in the reader's line of sight at the moment of the edit.
|
||||
`lib/common/constant.dart` carries one: the delay-test concurrency cap is bound to `delayTestConcurrency` in
|
||||
`core/common.go`, and whoever changes that number must see the constraint on the same screen. This is the case a
|
||||
comment is for; the bar is that no test and no `.agents/` entry could hold the fact instead.
|
||||
|
||||
Both failure directions are real. Moving a local constraint into `.agents/` hides it from the person editing the line;
|
||||
leaving a repo-wide policy as a comment reaches only the reader of that one file.
|
||||
|
||||
## Core API Safety
|
||||
|
||||
- `core/Clash.Meta` is a fork of mihomo, and changes to it are budgeted for features, not repairs. Fixing a bug there is
|
||||
low priority even when the bug is real and the fix is small: every patch is one more thing to carry across an upstream
|
||||
rebase. Solve it on the FlClash side of the boundary and note the mihomo behaviour you are working around. Reach into
|
||||
the submodule only for a feature that has nowhere else to live, or when the problem is one the FlClash patches
|
||||
themselves introduced — and say which of the two it is in the commit message.
|
||||
- Do not expose direct filesystem deletion APIs through Core or helper IPC; use
|
||||
a scope-specific cleanup API instead.
|
||||
- The desktop IPC socket in `plugins/rust_api` admits a Unix peer only when its effective uid is the app's own or root:
|
||||
the socket lives in `/tmp`, and the Core connects as root whenever it runs setuid for TUN or under the Linux Helper.
|
||||
A launch mode that runs the Core as any other user has to widen `authorize_peer` in `ipc/platform.rs`, and Windows
|
||||
keeps its identity check on the Dart side, which compares the named-pipe peer PID with the Core it launched.
|
||||
- Keep the shared `CoreMethodCall`/`CoreMethodResponse` JSON envelope structurally identical across Dart, Go, JNI, and
|
||||
desktop IPC. Do not double-encode `arguments`, `result`, or event batches.
|
||||
- `core/message.go` carries three event queues, and the split is load-bearing: state (loaded, geo-update,
|
||||
route-changed), priority (every other event), and bulk (log, request, DNS). Priority and bulk evict their own oldest
|
||||
entry under backpressure, except DNS, which enters the bulk queue through `enqueueState` so a lookup burst cannot push
|
||||
out the request events the connection list is built from; state uses `enqueueState`, which never evicts, because a dropped `geoUpdate{updating:false}` leaves `isUpdatingProvider` stuck at true in the UI until
|
||||
`UpdatingAction` sweeps it as stale minutes later. Do not merge the tiers or give state eviction semantics. `enqueueState` drops silently on a full
|
||||
queue and must stay that way: reaching it means the host stopped reading, which `logDeliveryError` already reports,
|
||||
and reporting it from the message layer feeds the same batcher.
|
||||
- `jni_get_string` in `android/core/src/main/cpp/jni_helper.cpp` `malloc`s and hands ownership to Go, which frees through
|
||||
`free_string_func`. `quickSetup` relies on that: it reads its `*C.char` arguments inside a goroutine, after the JNI
|
||||
wrapper has already returned. Switching the wrapper to `GetStringUTFChars`/`ReleaseStringUTFChars`, or freeing on the
|
||||
C side, turns that read into a use-after-free.
|
||||
- Go goroutines reach Java through `ATTACH_JNI()`, which attaches once with `AttachCurrentThreadAsDaemon` and detaches
|
||||
from a `pthread_key` destructor at thread death. Do not restore a detach-per-call: `protect` runs once per outbound
|
||||
socket and `onResult` once per event batch, and attach/detach takes ART's thread-list lock each time.
|
||||
- Every JNI call into Kotlin must be followed by `jni_clear_exception`. A pending exception left in place aborts the
|
||||
process on the next JNI call on that thread, so a throw in `protect`/`resolveUid`/`resolvePackage`/`onResult` becomes a
|
||||
crash in unrelated code. For the same reason every one of those wrappers checks its `tun_interface`/`invoke_interface`
|
||||
for `nullptr` first: ART aborts on a call through a null object, and a callback released by `TunHandler.clear` while a
|
||||
connection is still resolving is exactly that.
|
||||
- The Android bridge resolves an owner in two steps — `resolve_uid` then `resolve_package` — because mihomo fills
|
||||
`metadata.Uid` from a procfs lookup that Android Q closed off. Collapsing them back into one call that returns only a
|
||||
package name is what left every connection reporting uid 0, so `UID` rules matched nothing.
|
||||
- The desktop delivery path in `core/server.go` must not report failures through `logError`. A log event is published to
|
||||
the log subscriber, batched, and handed back to `send`, so a send failure reported that way feeds itself; use
|
||||
`logDeliveryError`, which writes to stderr and latches until a frame gets through or the next connection is installed.
|
||||
A write that fails without putting a byte on the wire — host backpressure hitting `ipcWriteTimeout`, or a payload above
|
||||
`maxIPCFrameSize` — drops that one frame and keeps the connection: the stream is still framed correctly, and tearing it
|
||||
down here ends the read loop, and with it the Core process. A half-written frame whose write merely timed out is
|
||||
resumed for as long as the stall lasts: Windows Modern Standby suspends the app while the Helper's Core keeps running,
|
||||
so the host can stop draining for hours and still come back, and go-winio reports the expiry as its own `ErrTimeout`
|
||||
rather than `os.ErrDeadlineExceeded`, which is why `send` checks `Timeout()`. The wait has no cap on purpose, and
|
||||
`send` holds `writeMu` throughout, so every other frame — method responses and the single batcher goroutine behind
|
||||
the event queues — waits behind the stalled one. Memory is bounded by the queues; what gives is delivery: the state
|
||||
queue fills and `enqueueState` starts dropping, which is the case the `UpdatingAction` sweep above exists for. Nothing
|
||||
on the Dart side restarts the Core over a stall: `CoreRpcClient` times each pending request out on its own and hands
|
||||
the caller `null` (a `no_response` exception for message methods), and the sweep clears core-scope updating state
|
||||
minutes later. Only a half-written frame that fails outright desynchronizes the stream, and that is the one case
|
||||
`send` closes on.
|
||||
- Core method handlers in `core/hub.go` are synchronous. Anything that must not block the dispatcher is spawned by
|
||||
`safeGo`/`safeGoDetached` in `core/method.go`, which recover; a bare `go` in a handler puts a panic outside every
|
||||
recovery and kills the process, which on Android is the whole application. The `//export` entry points in
|
||||
`core/lib.go` do not reach `handleMethodCall`, so each one carries its own recovery.
|
||||
- `dialer.DefaultSocketHook` and `process.DefaultPackageNameResolver` are installed exactly once, by `installHooks` in
|
||||
`core/lib.go`, and never cleared. mihomo checks `DefaultSocketHook` for nil once and dereferences it again when the
|
||||
socket is created (`component/dialer/socket_hook.go`), so clearing it while a dial is in flight calls a nil func
|
||||
value. Stopping the TUN swaps `activeTunHandler` instead.
|
||||
- `tunnel.AllProxies()` returns a shared, cached map — never modify it. The cache is invalidated by
|
||||
`invalidateAllProxies` on `tunnel.UpdateProxies` and validated against each provider's `Version()`, so a rebuild
|
||||
costs one read per provider rather than one per proxy. Anything else added to `tunnel/patch.go` that derives from the
|
||||
proxy set needs both signals: the external controller can reload the config through `hub/route/configs.go` without
|
||||
going through FlClash's `applyConfig`, so a hook on the FlClash side alone would miss a profile switch.
|
||||
- Core state that mirrors mihomo state goes stale at the next `applyConfig`, which replaces every proxy, provider and
|
||||
rule. Read the tunnel instead of caching a snapshot of it: `lookupExternalProvider` kept one that was rebuilt only
|
||||
when the host asked for the provider list, and the host asks after a successful setup and not after a failed one, so
|
||||
an update ran against a provider the tunnel no longer held — downloading, writing to disk and reporting success
|
||||
against nothing.
|
||||
- Selection writes take `selectMu`, not `configMu`. mihomo's `Selector.Set` has no lock of its own, so the writes need
|
||||
mutual exclusion against each other and against `patchSelectGroup` — but not against a whole config apply, which is
|
||||
what `configMu` made a proxy switch wait for, provider downloads included. `patchSelectGroup` takes `selectMu` under
|
||||
`configMu`, fixing the order as `configMu` → `selectMu`.
|
||||
- Queueing for the delay-test semaphore and probing the node each get the full timeout in `handleTestDelay`. With one
|
||||
shared deadline, a node that waited behind a saturated semaphore reached `URLTest` with almost nothing left, so a bulk
|
||||
test of a large subscription reported whatever sat at the back of the queue as unreachable.
|
||||
- A delay test that the Core does not answer is a fault of the Core or the channel, never a verdict on the proxy:
|
||||
`handleTestDelay` returns within its two timeouts on every path. `asyncTestDelay` therefore returns null instead of a
|
||||
`-1` delay, and `ProxiesAction` leaves the last measurement in place and abandons the rest of the run. Writing a
|
||||
timeout there is what made a reachable node read as unreachable whenever the host deadline beat the Core's.
|
||||
- Delay-test progress lives in `pendingDelayTestsProvider`, not as a sentinel value in `DelayDataSource`. A delay of 0
|
||||
used to mean "testing", which let a result and the state of a test overwrite each other and left cards spinning
|
||||
forever when the Core restarted. The run owns its keys and releases them in a `finally`, so nothing depends on a
|
||||
reply arriving; core status leaving `connected` cancels every run in flight.
|
||||
- Anything on the mihomo side that is reached from both a user-triggered core method and mihomo's own background
|
||||
scheduler needs its in-flight guard on the FlClash side. `updater.UpdateMMDB` and its siblings have none — only the
|
||||
batch `UpdateGeoDatabases` does — and two concurrent runs close the mmap'd database twice, so `handleUpdateGeoData`
|
||||
claims per resource and `updater.GeoUpdateHook` releases.
|
||||
- A failed `applyConfig` rolls the tunnel back to the default config, and that rollback is the whole recovery:
|
||||
`handleSetupConfig` returns the error and stops there. Do not add a teardown on top of it — stopping the listeners
|
||||
takes the app offline over a profile the user can still switch away from, and the error already reaches the host,
|
||||
which is what surfaces the failure (`MessageException` on the Flutter side, the config-error toast on Android). Keep
|
||||
an empty `config.yaml` out of the failure path — it is how the app says "no profile selected", and `loadConfig`
|
||||
resolves it to the defaults.
|
||||
- Package `init` in the Android library runs while the `.so` is being loaded, so a panic there takes the application
|
||||
down before it can report anything. `platform/limit.go` arms an fd-pressure probe and degrades to never blocking when
|
||||
it cannot; keep that shape for anything else `init` sets up that correctness does not depend on.
|
||||
- Every `android && cgo` file in `core/` is compiled only by the NDK-backed CI step in the `go` job. Keep the build
|
||||
constraints as `android && cgo` / `!(android && cgo)`: a bare `cgo` constraint makes `go build ./...` fail in `core/`
|
||||
on any developer machine, because the files it pulls in need the NDK.
|
||||
|
||||
## Lifecycle Rules
|
||||
|
||||
- Crash recovery is owned by `BootGuard` (`lib/common/boot_guard.dart`), and the signal it acts on is the persisted
|
||||
`BootRecord`, never a crash reporter. The record is stamped `starting` before `startCore` and `running` once
|
||||
`_initApp` finishes, so only a launch that died before reaching `running` is a failed launch — a crash after hours of
|
||||
runtime, or a process the system reclaimed in the background, is not one. `ApplicationExitInfo` can only veto a
|
||||
failure (user stop, low memory, signal, package change), never create one, and its timestamp is consumed through
|
||||
`handledExitAt` so the same exit cannot be counted twice.
|
||||
- `BootGuard` is an Android-only mechanism and gates itself: on every other platform `evaluate`, `markRunning` and
|
||||
`markClosed` return without touching preferences. The desktop has neither of the two attribution sources — no
|
||||
`ApplicationExitInfo` equivalent and no Crashlytics toggle — so a bare sentinel there would read a window closed on
|
||||
the disclaimer dialog, or a session ended by shutdown, as a failed launch. Keep the platform check inside the guard;
|
||||
callers in `bootstrap.dart` and `SystemAction` stay unconditional.
|
||||
- `FirebaseCrashlytics.didCrashOnPreviousExecution()` corroborates a failure and must not trigger one. Its marker file is
|
||||
cleared by a background initialization that `dataCollectionArbiter` blocks while collection is disabled, which is the
|
||||
default here, so one real crash makes it return true on every later launch. That latch is what made the app clear the
|
||||
selected profile on every cold start; it is also why the probe is only read when `crashlytics` is enabled, keeping
|
||||
Firebase uninitialized until the user consents.
|
||||
- Recovery is graded: the first failed launch only skips `initStatus`, and `currentProfileId` is cleared only from
|
||||
`crashRecoveryClearThreshold` consecutive failures on. Do not let a single interrupted launch write to the config.
|
||||
- Desktop process ownership belongs to `DesktopCoreLifecycle`; do not start/kill `FlClashCore` from providers, widgets,
|
||||
managers, or ad hoc exit callbacks. Acquire and release it through a `CoreProcessLease`.
|
||||
- `CoreController.close()` and platform `close()` implementations are terminal and idempotent. Application shutdown must
|
||||
stay centralized in `SystemAction`/`SystemExitCoordinator`.
|
||||
- Android start/stop MethodChannel calls are optimistic UI commands. Keep latest-wins arbitration in native
|
||||
`ServiceState`; do not add a Flutter completion callback that creates a second lifecycle owner.
|
||||
- Android service callbacks are not automatically user intent. Route explicit Quick Settings, Always-on VPN, and revoke
|
||||
actions through `ServiceState` and keep `ServiceController` as the sole binding/run-time owner.
|
||||
- Every `BroadcastReceiver.goAsync()` path must finish its `PendingResult` exactly once. A watchdog may release the
|
||||
broadcast lease, but must not cancel, reverse, or otherwise redefine the service operation.
|
||||
- Anything that probes through the outbound route is a `RoutedProbe` judged against `routeTrackerProvider` (Route
|
||||
Consistency in `.agents/architecture.md`); the Core stamps every answer and publishes its picks, so do not add a
|
||||
change signal, a trigger counter, or a `coreStatusProvider` watch of your own.
|
||||
- Presentation smoothing such as `CoreStatusButton`'s connecting hold must remain local display state. It must not delay or
|
||||
overwrite `coreStatusProvider`, and a real failure must bypass/cancel the hold immediately.
|
||||
- Safe mode (`SAFE_MODE` define, see the safe mode section of `.agents/architecture.md`) is consulted by each host
|
||||
integration at its owner, never by rewriting the user's settings. `safeModeBuild` is for handles created before the
|
||||
container exists; everything else reads `safeModeProvider` so a test can override it.
|
||||
- `Tray.hide()` is idempotent on all three desktop platforms and returns native state to "`show` was never called".
|
||||
`AppTray.shutdown()` latches, so no later `update()`/`updateTitle()` can resurrect the icon once shutdown begins.
|
||||
Keep it that way; a resurrected icon outlives `exit(0)` as a Windows ghost icon, because `setPreventClose(true)`
|
||||
means `WM_DESTROY` never runs.
|
||||
- The `tray` plugin owns call ordering, idempotency, serialization, and unchanged-payload suppression. Application code
|
||||
declares desired state through one `Tray.show(TraySpec)` call and must not add platform branches to work around
|
||||
ordering. Platform branches in `lib/common/tray.dart` are only for deliberate product differences (macOS speed title
|
||||
and group submenus); query `Tray.instance.capabilities` for ability differences.
|
||||
- Every native `show` returns whether the tray now reflects the payload, and reports `false` instead of showing a broken
|
||||
icon. `Tray` caches the payload signature only on `true`, so a rejected `show` is retried by the next update rather
|
||||
than suppressed until restart. Any test that mocks the `tray` channel must return `true` from `show`.
|
||||
- Reading the Wi-Fi SSID is opt-in work, not ambient state. `ConnectivityManager` reads it only while `excludeSSIDs` is
|
||||
non-empty, because that list is its only consumer through `suspendProvider`, and the read costs a blocking platform
|
||||
call plus a location permission on Android and macOS. A second consumer must widen that gate, not drop it.
|
||||
- On Android the `wifi_ssid` permission is only `granted` once `ACCESS_BACKGROUND_LOCATION` is held too (Q+). Fine
|
||||
location alone reads the SSID in the foreground but returns `UNKNOWN_SSID` once the app is backgrounded, which is
|
||||
where on-demand spends most of its time. `getSsid` itself only needs fine location, so the split stays inside the
|
||||
plugin.
|
||||
- No `wifi_ssid` implementation may answer `getSsid` on the platform thread. Android resolves through a network callback
|
||||
with a timeout, Linux through `g_task_run_in_thread`, and macOS through `ssidQueue` after checking the location
|
||||
authorization, because CoreWLAN reaches `wifid` over XPC and a wedged daemon would freeze the window. Windows is the
|
||||
outstanding exception: `WlanQueryInterface` still runs inline, since replying off-thread there needs a window-message
|
||||
hop that the plugin API does not provide.
|
||||
- The delayed DNS re-check `NetworkObserveModule.onLosing` posts is deliberately left un-deduplicated. The runnable
|
||||
re-reads `networkInfos` and does nothing when `onLost` already dropped the network, `updateDns` returns early when the
|
||||
resolved list is unchanged, and `stop()` clears the handler queue, so a network that reports `onLosing` repeatedly
|
||||
costs one comparison per event. Holding the pending `Runnable` to `removeCallbacks` it changes no behaviour and adds
|
||||
state that has to stay in sync with the map.
|
||||
|
||||
## Testing Rules
|
||||
|
||||
The `core/` directory is excluded from automated coverage accounting. Do not add coverage instrumentation or coverage
|
||||
collection for code under `core/`. CI still runs `CGO_ENABLED=0 go test .` and `go vet .` to compile/check the Go wrapper,
|
||||
plus an NDK-backed `GOOS=android` vet that covers the `android && cgo` files the first two exclude; verify cross-language
|
||||
protocol behavior through shared Dart contract tests under `test/core/` and native platform build checks.
|
||||
|
||||
A Go test in `core/` that reaches `sendMessage` — directly, or through `handleStartLog` or `updater.GeoUpdateHook` —
|
||||
leaves events in the process-wide batcher, which flushes them up to `messageBatchInterval` later into whichever
|
||||
connection `captureFrames` has installed by then. Either keep the event out of the batcher or end the test with
|
||||
`settleMessageBatcher`. That batcher runs for the whole test binary and reads `conn` under `connMu`, so install a test
|
||||
connection with `swapConn`; a bare assignment races every event it happens to be delivering, and `go test -race` catches
|
||||
it in an unrelated test.
|
||||
|
||||
Register fallback values for freezed params used with `any()` matchers.
|
||||
|
||||
`tool/check_coverage.dart` enforces a total floor passed by CI plus per-group floors declared in `_groupFloors`. Raise a
|
||||
group's floor when new tests lift it; do not lower one to make a run pass.
|
||||
|
||||
Every measured group needs a floor. A group the report measures but `_groupFloors` does not declare fails the run, so
|
||||
adding a top-level directory under `lib/` means adding its floor in the same change. Set a new floor at or just below
|
||||
the coverage the directory actually has; the point is to stop a slide, not to backfill tests before the directory can
|
||||
land.
|
||||
|
||||
Prefer `coreHandlerProvider.overrideWithValue(CoreController.scoped(fake))` over `CoreController.test(fake)` in new and
|
||||
touched tests. `CoreController.test` claims the process-wide singleton, which makes a global read and a provider read
|
||||
resolve to the same fake, so it cannot fail on a call site that still reaches for the global. A test that does claim
|
||||
the singleton calls `CoreController.resetInstance()` in `tearDown`.
|
||||
|
||||
Construct the Android lib handler with `CoreLib.scoped(fakeService)`. The `service` global is gated on `Platform.isAndroid`
|
||||
and is therefore null on every test host, so a `CoreLib()` built from it silently takes the null-service fallback on every
|
||||
path. `CoreLib.scoped` binds an explicit `Service` instead; reset the singleton with `CoreLib.resetInstance()` in `tearDown`.
|
||||
|
||||
Three globals in `lib/common` reach real host state and carry a `@visibleForTesting` seam to stand in front of it:
|
||||
`AutoLaunch.launcher`, `listNetworkInterfaces` and `LinkManager.uriLinkStream`. Replace the launcher in particular — every
|
||||
`enable`/`disable`/`isEnabled` writes the actual autostart entry (a LaunchAgents plist, a `.desktop` file or a registry
|
||||
key), so a test that skips the seam registers the test binary on the machine that ran it. `updateStatus` returns early
|
||||
under `kDebugMode`, which is always true beneath `flutter test`, so its remaining branches cannot be reached from a test
|
||||
at all; `test/common/launch_test.dart` pins the early return instead.
|
||||
|
||||
`pumpAndSettle` never returns on a page holding `EditorView` (what `EditorPage` embeds): the code editor blinks its
|
||||
caret forever, so frames keep being scheduled. Pump explicitly instead. `encodeYamlTask` and its neighbours in
|
||||
`common/task.dart` hand work to a real isolate through `compute`, which only runs outside the fake-async zone, so a test
|
||||
awaiting one needs `tester.runAsync(...)` between the pumps.
|
||||
|
||||
The `@visibleForTesting` `database` setter in `lib/database/database.dart` deliberately does not close the instance it
|
||||
replaces. Tests inject `NativeDatabase.memory()`, which holds no file handle, and `Database.close()` is async while the
|
||||
setter is not, so closing there would either be unawaited or force the seam to become async for no gain. A test that
|
||||
does open a file-backed database owns closing it.
|
||||
|
||||
`system.isAndroid` / `isMacOS` / `isWindows` / `isLinux` read `dart:io` `Platform` and cannot be overridden, unlike
|
||||
`debugDefaultTargetPlatformOverride`. A branch behind one of them is only ever exercised on a host that matches it, so CI
|
||||
(`ubuntu-latest`) and a macOS working copy measure different coverage for the same test. Assert host-agnostic behavior,
|
||||
and leave headroom under a group floor that covers such a branch.
|
||||
|
||||
A platform decision that drives layout takes `isDesktop`/`isMacOS` as parameters and reads `system` only at the call site,
|
||||
so every platform's outcome is reachable from one host. `getWindowHeaderHeight` and `showsWindowHeader` in
|
||||
`lib/common/layout.dart` own the window header rule for both `WindowHeaderContainer` and `overlayTopOffset` — they must
|
||||
agree, or the content is offset by a header that is not there. `WindowHeaderBar` takes its height and slots as arguments,
|
||||
which is what lets `test/manager/window_header_test.dart` measure the Windows caption bar on a macOS host.
|
||||
|
||||
A `Stack` under loose constraints sizes to its non-positioned children, and falls back to `constraints.biggest` only when
|
||||
it has none — so a bar that fills the window must keep every slot positioned. One non-positioned child is enough to
|
||||
collapse `WindowHeaderBar` to that child's width: the macOS title did exactly that, leaving the app name over the traffic
|
||||
lights and the raw window painted black beside it, while Windows, whose slots are all positioned, stayed correct.
|
||||
`WindowHeaderLayout` positions the header across the top for the same reason, and the macOS group asserts the bar's
|
||||
width, not just its height — a height-only assertion cannot see this.
|
||||
|
||||
The same seam carries the two other places a platform decision changed what was built: `AppTray` holds `isMacOS`/
|
||||
`isWindows` as state — `AppTray()` fills them from `system`, `AppTray.forPlatform` is the test seam — and `OnDemandView`
|
||||
takes nullable `isAndroid`/`isMacOS` that fall back to the host. Every test names the platform it means, because
|
||||
`debugDefaultTargetPlatformOverride` does not move `system` and a suite that leaves it to the host asserts the macOS
|
||||
shape on a developer machine and the Linux one on CI. `WindowHeaderContainer` builds the caption buttons on every
|
||||
non-macOS host, so a test mounting it needs `TestApp` for `AppLocalizations` and a `window` channel mock that
|
||||
answers `isMaximized`/`isAlwaysOnTop` with a bool.
|
||||
|
||||
Auto-dispose providers need a container-level hold before a test reads them back. `proxyGroupProvider`, `ruleProvider`,
|
||||
`itemsProvider` and friends mix in `AutoDisposeNotifierMixin`, so a `container.read` that no widget is currently watching
|
||||
rebuilds the provider from its override and silently discards whatever the code under test wrote. Add
|
||||
`container.listen(theProvider, (_, _) {})` in the harness, as `overwrite_stage_flow_test.dart` does. The staging flow also
|
||||
re-arms its debounce when it clears the stage, so drain it (`pump` past the duration, then unmount) or the binding fails
|
||||
the test on a pending timer.
|
||||
|
||||
A field that constructs its own `ValueNotifier`, `TextEditingController`, `ScrollController`, `FocusNode`, `TabController`,
|
||||
`PageController`, `AnimationController` or `StreamController` must be released in the same file.
|
||||
`test/lint/disposable_field_test.dart` enforces this by scanning `lib/`, because no lint covers it: `close_sinks` only sees
|
||||
sinks, and nothing in the standard set tracks `ChangeNotifier` disposal. A field that genuinely outlives its owner goes in
|
||||
that test's `_allowed` set with the reason, not left bare. Controllers received as widget parameters belong to the caller
|
||||
and are out of scope.
|
||||
|
||||
An `IconButton` whose icon is an icon needs a `tooltip`. It is the button's only accessible name — without it TalkBack and
|
||||
VoiceOver announce nothing and the desktop build shows no hover hint. `test/lint/icon_button_tooltip_test.dart` enforces
|
||||
it and skips exactly two shapes: an `icon:` holding a `Text`, which is already a visible label, and
|
||||
`views/dashboard/widgets/core_status_button.dart`, which takes its label from an enclosing `Tooltip` (a second test fails
|
||||
if that wrapper disappears). Reuse an existing string before adding one; a label that depends on state goes on the button
|
||||
inside the `ValueListenableBuilder`, not outside it, or the tooltip cannot follow the icon. A row of window buttons hidden
|
||||
behind `system.isMacOS` is unreachable from a macOS test host, so extract it — `WindowHeaderActions` is the pattern.
|
||||
|
||||
A tooltip needs an `Overlay` ancestor at build time, not at hover time: `RawTooltip` builds an `OverlayPortal`, so a
|
||||
button whose `tooltip` has nowhere to go throws "No Overlay widget found" and takes its whole subtree down with it.
|
||||
Everything `buildManagerStack` wraps around `MaterialApp.builder`'s child sits *above* the app Navigator and therefore
|
||||
above the only Overlay in the tree. A manager that renders a tooltip — `WindowHeaderLayout` and its caption buttons are
|
||||
the case that broke Windows while macOS, which renders a bare title there, stayed clean — hosts its own with
|
||||
`Overlay.wrap`, spanning the window so the tooltip is not clipped to the widget that owns it. A test only reproduces this
|
||||
by mirroring that topology: build the widget from `MaterialApp.builder`, never from `home:`.
|
||||
|
||||
A public top-level declaration that nothing outside its own file references is dead, and no lint catches it:
|
||||
`unused_element` covers only private ones, and a barrel `export` keeps a dead file compiling and off every
|
||||
"unused import" report. `test/lint/dead_file_test.dart` scans `lib/` for files whose declared names — types plus the
|
||||
`final appPath = AppPath()` singletons next to them — appear nowhere else, counting generated code as a consumer (a
|
||||
riverpod notifier is reached through its generated provider) and barrels as neither. Files publishing only extensions or
|
||||
typedefs are skipped: those are reached through the types they attach to, never by name.
|
||||
|
||||
A `State.dispose()` override must not await before `super.dispose()`. `StatefulElement.unmount` calls `dispose()` and then
|
||||
immediately asserts that `super.dispose()` already ran, so an `await` defers the call past the assert and every teardown
|
||||
throws "`…State.dispose failed to call super.dispose.`" in debug and profile builds. Declare the override as `void
|
||||
dispose()` and hand async teardown to `unawaited(...)`; `Future<void> dispose() async` compiles and is the shape that
|
||||
invites the bug.
|
||||
|
||||
Use `ProviderContainer` directly for simple Riverpod provider tests, released with `addTearDown(container.dispose)`.
|
||||
Assert against the provider's generated public API, not its implementation, re-read source defaults before asserting
|
||||
them, and wait on provider futures or state changes rather than fixed sleeps. 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.
|
||||
|
||||
For async widgets, put visual cleanup in `finally` when the action may throw. Focused widget tests should cover success,
|
||||
failure, disposal, and any timer boundary that changes visible state.
|
||||
|
||||
## Running the App Locally
|
||||
|
||||
- An agent starts FlClash on the host only as a safe mode build (`--dart-define=SAFE_MODE=true`, see
|
||||
`.agents/commands.md`). A normal build takes over the host network, so the user runs it, never the agent.
|
||||
- Never end a FlClash process the agent did not start, and never kill by name (`pkill`, `killall`, a pattern on the
|
||||
executable path): the user's own instance matches too. Record the pid of every process you launch, check with
|
||||
`ps -o pid,command -p <pid>` that its executable is your own build output, and end only that pid.
|
||||
- Only a macOS or Android debug build has its own data directory; every other build shares the installed app's data
|
||||
directory and single-instance lock, so it cannot start while the user's instance runs. `.agents/commands.md` lists
|
||||
which builds are kept apart.
|
||||
|
||||
## Commit Messages
|
||||
|
||||
Subjects follow Conventional Commits and are enforced by the `commit-msg` hook in `.pre-commit-config.yaml`, which runs
|
||||
`tool/check_commit_msg.sh`:
|
||||
|
||||
```text
|
||||
<type>[(scope)][!]: <description>
|
||||
```
|
||||
|
||||
- Types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`.
|
||||
- Scope is optional and lower case; use a comma to list several, as in `fix(core,android)`.
|
||||
- `!` before the colon marks a breaking change.
|
||||
- Descriptions start in lower case, omit the trailing period, and keep the whole subject within 100 characters.
|
||||
Identifiers and acronyms keep their own casing, as in `fix(ui): AppBar text is truncated`.
|
||||
- `Merge`/`Revert` subjects and `fixup!`/`squash!` commits are exempt.
|
||||
- No `Co-authored-by` trailer crediting a coding agent, whatever that tool's own convention says. The history
|
||||
records who owns the change, not which tool typed it; human co-authors are still fine. The hook rejects the
|
||||
known agent identities.
|
||||
|
||||
Write what the change does, not that something changed: `perf(views): stop redoing per-frame work in build`, not
|
||||
`Optimize more details`.
|
||||
|
||||
Install the hooks once with `pre-commit install --hook-type pre-commit --hook-type pre-push --hook-type commit-msg`.
|
||||
|
||||
### Fixup Commits
|
||||
|
||||
A follow-up to a change that has not shipped in a stable release goes in as `git commit --fixup=<target>`, aimed at the
|
||||
commit that introduced what it amends.
|
||||
|
||||
- Only stable tags (`vX.Y.Z`) close a commit. A target reached only by pre-release tags (`vX.Y.Z-pre.N`) is still open
|
||||
for fixups, so do not steer away from it or flag it.
|
||||
- A commit a stable tag contains takes no fixup; land the follow-up as a regular commit. Check with
|
||||
`git tag --contains <target> | grep -v -- '-pre'`, which must print nothing.
|
||||
- Asking for a fixup means creating the `fixup!` commit only. Do not run `git rebase --autosquash` or otherwise fold it
|
||||
into its target unless the user asks for the squash.
|
||||
|
||||
### Changelog Trailers
|
||||
|
||||
`tool/changelog.dart` builds the user facing changelog from the commit history, so the trailers below are the copy that
|
||||
ships to users. The subject stays the developer facing summary and is only the fallback.
|
||||
|
||||
```text
|
||||
feat(profiles): support per-profile override script
|
||||
|
||||
Changelog: Per-profile override scripts
|
||||
```
|
||||
|
||||
- `Changelog:` is the English entry. `Changelog: skip` drops the commit from the changelog entirely.
|
||||
- The changelog is English only. A `Changelog-<locale>:` or `Breaking-<locale>:` trailer fails the hook, because
|
||||
translated release copy does not belong in the commit history. Translate after the fact if ever needed, not in the
|
||||
commit message.
|
||||
- `Changelog-Type:` moves an entry into another group, for example to promote a `refactor` that users will notice. Valid
|
||||
values are `breaking`, `feat`, `fix`, `perf`, `revert`.
|
||||
- `BREAKING CHANGE:` is required whenever the subject carries `!`, and its text becomes the breaking entry. A `!` commit
|
||||
therefore needs two lines of copy: the footer for the breaking entry and `Changelog:` for the normal one.
|
||||
|
||||
`feat`, `fix`, `perf`, `revert` and breaking commits are collected by default; every other type is dropped unless it
|
||||
carries a `Changelog:` trailer. Commits missing a trailer reuse their subject; for `feat`, `fix` and `perf` the hook
|
||||
says so without blocking.
|
||||
|
||||
## 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.
|
||||
|
||||
`lib/l10n/l10n.dart` is the one file that still imports `package:flutter/material.dart`.
|
||||
`intl_utils` hardcodes that import in its own template, so regenerating rewrites it and
|
||||
there is nothing to fix here; `test/lint/design_package_test.dart` exempts the generated
|
||||
l10n paths for that reason. It is harmless because the file only needs `Locale`,
|
||||
`BuildContext`, `Localizations` and `LocalizationsDelegate`, which the legacy library and
|
||||
`material_ui` both re-export from the same `package:flutter/widgets.dart`. Everything a
|
||||
human writes takes Material from `material_ui`; `cupertino_ui` is banned outright and
|
||||
survives only as a transitive dependency of `material_ui`.
|
||||
|
||||
Strings live in `arb/intl_{en,zh_CN,ja,ru}.arb` — flat JSON, no `@` metadata. Add a key to all four, then regenerate with
|
||||
`dart run intl_utils:generate`, which rewrites `lib/l10n/`. A key present in only some locales silently falls back to
|
||||
English at runtime, so add the translation rather than leaving it out.
|
||||
|
||||
Some labels are not reached through the generated `AppLocalizations` getters at all. `Intl.message(<runtime string>)`
|
||||
builds the key from an enum name or a stored string — `action_${HotAction.name}`, `${DynamicSchemeVariant.name}Scheme`,
|
||||
`NavigationItem.description`. The analyzer sees nothing, and a failed lookup returns the key itself, so a stale key ships
|
||||
as `routeMode_config` in the UI rather than throwing. `test/lint/dynamic_message_key_test.dart` expands those families
|
||||
from the real enums and fails when a derived key is missing from any locale; every `Intl.message` site in `lib` must be
|
||||
registered there, so a new dynamic key cannot be added without also declaring what builds it.
|
||||
@@ -0,0 +1,24 @@
|
||||
# Agent Skills
|
||||
|
||||
Repo skills live under `.agents/skills/*/SKILL.md`, where Codex discovers them directly. Claude Code discovers them
|
||||
through the symlinks in `.claude/skills/`, one per skill, pointing back at the `.agents/skills/` directory. Both tools
|
||||
match a task against the skill `name` and `description`, then load the full instructions only when it matches.
|
||||
|
||||
## Available Repo Skills
|
||||
|
||||
- `localization`: hardcoded UI text scans, ARB updates, locale generation, and localization verification.
|
||||
- `ui-work`: Flutter UI, widgets, Material You styling, navigation surfaces, async feedback, and user-facing interactions.
|
||||
- `core-platform`: Core lifecycle/process ownership, Android services, Go event delivery, desktop IPC, platform managers,
|
||||
VPN/TUN, and Windows Helper flow.
|
||||
- `pre-commit-quality-review`: lightweight triage before commits, with scoped structural review and user decisions on concrete regressions.
|
||||
|
||||
## Authoring Notes
|
||||
|
||||
- Add new repeatable workflows as `.agents/skills/<skill-name>/SKILL.md`, and add the matching
|
||||
`.claude/skills/<skill-name>` symlink so Claude Code sees it too; remove both when retiring a skill.
|
||||
- Git stores the symlinks as links only where the checkout supports them. A Windows checkout needs
|
||||
`core.symlinks=true` and Developer Mode, otherwise the links become plain files and Claude Code loads no repo
|
||||
skill there; Codex is unaffected because it reads `.agents/skills/` directly.
|
||||
- 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.
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
name: core-platform
|
||||
description: Use when changing FlClash Core integration, lifecycle/process ownership, Go event delivery, Android services, desktop IPC, platform managers, build hooks, system proxy, tray, VPN/TUN, or the Windows or Linux Helper.
|
||||
---
|
||||
|
||||
# Core And Platform
|
||||
|
||||
## When To Use
|
||||
|
||||
Use this for changes touching `lib/core/`, `lib/manager/`, `core/`, `services/helper/`, Android app/service modules,
|
||||
build hooks, system proxy, tray, VPN, TUN, or platform-specific desktop/mobile behavior.
|
||||
|
||||
## Read First
|
||||
|
||||
- `.agents/rules.md`: Core API Safety and Lifecycle Rules are binding. They carry the mihomo-fork patch budget, the
|
||||
JSON envelope and `core/message.go` queue rules, JNI ownership, `BootGuard`, tray, and SSID rules, and every
|
||||
lifecycle invariant (latest intent wins, desktop ownership through a `CoreProcessLease`, terminal `close()`,
|
||||
optimistic Android commands arbitrated by `ServiceState`, callbacks are not user intent, `goAsync()` finishes once).
|
||||
- `.agents/architecture.md`: Core Integration, Lifecycle Ownership And Convergence, Core Protocol And Event Delivery,
|
||||
Safe Mode, Build System, and Rust Helper Service describe the current mechanisms, including the Helper protocol,
|
||||
the applied/coalesced/superseded outcomes, and the unconfirmed-exit lease.
|
||||
|
||||
## Owners
|
||||
|
||||
Every behavior here has one authoritative owner, and a change lands there:
|
||||
|
||||
- Shared facade/protocol: `lib/core/controller.dart`, `lib/core/interface.dart`, and `lib/core/method.dart`.
|
||||
- Android Core connection: `lib/core/lib.dart`, `lib/plugins/service.dart`, and Android `ServicePlugin`.
|
||||
- Android start/stop intent: `ServiceState`; binding/process-time bookkeeping: `ServiceController`.
|
||||
- Desktop composition: `lib/core/service.dart`; lifecycle/process ownership: `lib/core/desktop/lifecycle.dart`.
|
||||
- Desktop IPC/RPC: `lib/core/desktop/transport.dart` and `lib/core/desktop/rpc_client.dart`.
|
||||
- Desktop launch ownership: `lib/core/desktop/launcher.dart`; Windows and Linux Helper HTTP contract:
|
||||
`lib/core/desktop/helper_client.dart` and `services/helper/`.
|
||||
- Flutter orchestration: `lib/providers/actions/core.dart` and `system.dart`; application exit:
|
||||
`SystemExitCoordinator`; UI/event observation: `lib/manager/`.
|
||||
|
||||
An owner has more entry paths than the UI: provider calls, Quick Settings, notification actions, Always-on VPN, revoke
|
||||
callbacks, application exit, and crash/disconnect recovery all reach it. Always-on startup is explicit through
|
||||
`VPN_START_REQUESTED` and revoke through `VPN_REVOKED`; service creation or destruction is never read as intent.
|
||||
|
||||
## Verification
|
||||
|
||||
Focused tests live at the narrowest layer, and the matching commands are in `.agents/commands.md`:
|
||||
|
||||
- Desktop lifecycle/transport/RPC: `test/core/desktop/` plus `test/core/service_test.dart`.
|
||||
- Cross-language envelopes/events: `test/core/protocol_contract_test.dart` and `CGO_ENABLED=0 go test .`.
|
||||
- Provider/exit convergence: `test/providers/action_test.dart` and `test/providers/system_action_test.dart`.
|
||||
- Android Kotlin: compile each touched Gradle module with JDK 17.
|
||||
- Helper: `cargo fmt --check` and `cargo test`; the `windows-service` feature only builds on Windows.
|
||||
|
||||
State host gaps explicitly. Always-on VPN, VPN permission, system revoke, named-pipe peer identity, polkit and systemd
|
||||
on Linux, and Windows Service Control Manager behavior need their real platform even when portable tests pass.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- A `200` from Helper `/stop` means the Core is gone. When termination cannot be confirmed the Helper keeps the child
|
||||
and answers `coreStopFailed`, and `/start` reports the same code instead of spawning a replacement. Keep that code
|
||||
out of the Dart pre-spawn fallback set in `helper_client.dart`, or the direct launch races a Core the Helper still
|
||||
owns.
|
||||
- The Helper owns executable integrity: Core SHA256 validation and the protocol stay identical across Flutter build
|
||||
modes, so a debug build is not exempt.
|
||||
- `plugins/setup/` is a build harness, not a Dart API plugin, and its hooks can trigger Go or Rust compilation
|
||||
indirectly through Flutter platform builds; a missing toolchain fails `flutter test`, not only `flutter build`.
|
||||
@@ -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 in this repository, 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. Scan user-facing Dart text before opening many files. `flutter_intl` reads `arb/` and writes `lib/l10n/`:
|
||||
|
||||
```bash
|
||||
rg -n "[\\p{Han}]" lib -g '!lib/l10n/intl/**' -g '!lib/**/generated/**'
|
||||
```
|
||||
|
||||
2. Inspect the smallest relevant call sites and nearby ARB keys.
|
||||
3. Add or update every source ARB:
|
||||
- `arb/intl_en.arb`
|
||||
- `arb/intl_zh_CN.arb`
|
||||
- `arb/intl_ja.arb`
|
||||
- `arb/intl_ru.arb`
|
||||
4. Replace inline strings with existing project accessors:
|
||||
- Widgets with `BuildContext`: `context.appLocalizations.key` from `lib/common/context.dart`.
|
||||
- Controllers/providers/non-widget code: `currentAppLocalizations.key` from `lib/common/app_localizations.dart`.
|
||||
5. Regenerate:
|
||||
|
||||
```bash
|
||||
dart run intl_utils:generate
|
||||
```
|
||||
|
||||
6. Run `flutter analyze --no-fatal-infos` and `flutter test test/l10n test/lint/dynamic_message_key_test.dart`. The
|
||||
lint test covers the labels built from a runtime key, such as `action_${HotAction.name}`, which the analyzer cannot
|
||||
see; the ARB section of `.agents/rules.md` explains them.
|
||||
7. 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.
|
||||
@@ -0,0 +1,105 @@
|
||||
---
|
||||
name: pre-commit-quality-review
|
||||
description: Use when the user asks to commit, amend, or squash code, or requests a pre-commit structural quality review.
|
||||
---
|
||||
|
||||
# Pre-commit Quality Review
|
||||
|
||||
Check that iterative edits converged into a clear, cohesive, maintainable implementation before they become a commit,
|
||||
so later code review is not spent on basic structure. `AGENTS.md` and `.agents/rules.md` apply throughout. This skill
|
||||
grants no authority: commit, push, and history rewrites stay under the user's existing authorization, and silence is
|
||||
not consent.
|
||||
|
||||
## Triage
|
||||
|
||||
Most commits stop here. Decide from the change summary, the diff, and the local context it needs; never start with a
|
||||
repository-wide scan.
|
||||
|
||||
- **Skip** formatting, mechanical renames, generated output, copy changes, and simple local fixes. They go through the
|
||||
normal commit flow without extra confirmation. Say the review was skipped, never that it passed.
|
||||
- **Review** when a core implementation is replaced; when responsibilities, dependency direction, or public abstractions
|
||||
change materially; when accumulated patches added branches, duplicate paths, or indirection; or when new code draws
|
||||
responsibility boundaries. A small change can move a critical responsibility, so line count is not a trigger.
|
||||
- An explicit structural review request bypasses triage.
|
||||
|
||||
The snapshot under review is the intended commit compared with its baseline:
|
||||
|
||||
| Operation | Baseline |
|
||||
|---|---|
|
||||
| Regular commit | `HEAD` |
|
||||
| Amend | Parent of the commit being replaced |
|
||||
| Squash | Version preceding the agreed range |
|
||||
| Initial commit | Empty tree |
|
||||
|
||||
Ask when the range or parent is materially ambiguous instead of assuming the whole branch. Read staged versions of
|
||||
partially staged files; an unstaged fix is not part of the pending commit. Preserve unrelated changes and partial
|
||||
staging.
|
||||
|
||||
## Review the Final Structure
|
||||
|
||||
Scope the review to the responsibility boundaries this change touched. Read the complete final implementation of the
|
||||
affected units and compare before and after, not only the last patch. Report issues this change introduced or
|
||||
worsened; untouched historical debt is not a pre-commit requirement. Follow a call or dependency path only as far as a
|
||||
concrete concern needs, and disclose missing essential context rather than claiming a complete pass.
|
||||
|
||||
Focus on:
|
||||
|
||||
- **Responsibilities and cohesion:** units mixing responsibilities that change independently; orchestration tangled
|
||||
with implementation detail; responsibilities in layers that should not own them.
|
||||
- **Dependencies and abstractions:** reversed or circular dependencies, forwarding layers, abstractions that isolate
|
||||
nothing; similar names or interfaces hiding different responsibilities.
|
||||
- **Complexity and readability:** nested conditions, repeated fallbacks, boolean parameter combinations, mutually
|
||||
dependent helpers; names and decomposition that do not reduce reading effort.
|
||||
- **Duplication and consistency:** reimplemented existing capabilities, parallel implementations or duplicate state,
|
||||
scattered ownership of error handling, cleanup, or state updates.
|
||||
- **Remnants:** obsolete branches, compatibility layers, unused parameters, commented-out code, comments the rewrite
|
||||
invalidated; coupling that makes existing test boundaries hard to use.
|
||||
|
||||
Not findings: length, a missing design pattern, naming taste, or anything without code evidence and a real
|
||||
maintenance cost. Do not add cross-layer coupling to remove a few similar lines, rewrite stable code for uniformity,
|
||||
or demand abstractions for speculative needs.
|
||||
|
||||
Business logic, UI behavior, performance, security, platform compatibility, and full functional review are out of
|
||||
scope. Report an incidentally found serious defect separately through the normal defect workflow, without hiding it or
|
||||
widening this review.
|
||||
|
||||
## Findings and the User's Decision
|
||||
|
||||
A finding qualifies only when all three hold:
|
||||
|
||||
- Before-and-after code and the necessary call relationships show this change introduced or worsened it.
|
||||
- The maintenance cost is concrete, such as updating one rule in two places or touching unrelated layers for one
|
||||
responsibility. Clarity or elegance alone is not enough.
|
||||
- A minimal proposal fits existing constraints, with a benefit not offset by new coupling, indirection, or scope.
|
||||
|
||||
With no qualifying finding, state the reviewed scope and conclusion in a sentence or two and continue under the
|
||||
existing commit authorization. Do not ask for confirmation because a review happened, and do not collect optional
|
||||
improvements into a suggestion list.
|
||||
|
||||
With qualifying findings, finish the read-only review and independent checks first, merge findings that share a cause,
|
||||
and give for each: file, symbol, and location with how this change introduced it; the concrete maintenance cost; the
|
||||
smallest proposal with its scope and any behavioral impact. Then **pause before creating the commit and ask whether to
|
||||
optimize first; do not edit or commit until the user answers.** Attribute the pause to this skill by file path so the
|
||||
user recognizes their configured workflow, for example: "The structural issues above support consolidating the
|
||||
duplicate state-update paths. Should I make that change before committing?"
|
||||
|
||||
Existing explicit authorization for these findings is enough to act without asking again. The user may accept all,
|
||||
some, or none. Keep accepted findings and tradeoffs in the task context only, and do not re-ask because files were
|
||||
restaged, a hash changed, or unrelated edits landed; reassess only when relevant code changes invalidate the tradeoff.
|
||||
|
||||
## Resolve and Finish
|
||||
|
||||
Once authorized, make the smallest adjustments that resolve the accepted findings while preserving behavior and task
|
||||
scope; explain separately if either must change. Verify only the accepted findings and the direct effects of the
|
||||
adjustments, without another search for improvements. Raise a new finding only with evidence that meets the threshold
|
||||
and exceeds the existing authorization.
|
||||
|
||||
Before committing, recheck status and the actual staged diff so the pending snapshot matches what was reviewed, and
|
||||
review only the delta of later edits. Run the checks `AGENTS.md` requires for the change: `flutter pub get`,
|
||||
`flutter analyze --no-fatal-infos`, and the covering `flutter test` suites listed in `.agents/commands.md`. Reuse
|
||||
results that are still valid, rerun those a later edit invalidated, and never present results from an earlier snapshot
|
||||
as verification of the staged one. The `comment-density` and `commit-msg` hooks in `.agents/rules.md` still gate the
|
||||
commit.
|
||||
|
||||
Report the reviewed scope, completed improvements or accepted tradeoffs, check results, and any verification limits.
|
||||
Claim the commit is done only after it exists.
|
||||
@@ -0,0 +1,108 @@
|
||||
---
|
||||
name: ui-work
|
||||
description: Use when changing FlClash Flutter UI, widgets, screens, Material You styling, icons or glyphs, shapes and corner radii, navigation surfaces, async feedback, 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.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Reuse the patterns of nearby widgets, providers, notifiers, and helpers before adding an abstraction, and keep to the
|
||||
Material You, Surfboard-like look the rest of the app has. Do not introduce a visual system for one screen or rewrite
|
||||
a layout the request does not need.
|
||||
- `.agents/rules.md` is binding here: the lint set (`child:` last, `const`, final locals), the superellipse corner API,
|
||||
and the widget-test facts under Testing Rules (`pumpAndSettle` never returns on `EditorView`, `dispose()` must not
|
||||
await, auto-dispose providers need a container-level hold, platform decisions are passed in as parameters).
|
||||
- Settings screens are built from the `ConfigToggleItem` family in `lib/widgets/config_item.dart`; the Settings Rows
|
||||
section of `.agents/architecture.md` says when a file-local helper or a named widget is warranted.
|
||||
- Every icon lives in `lib/icons/`, never the Material `Icons` font: reuse or add a glyph in `AppGlyphs` and show it
|
||||
with `GlyphIcon`, or with `AnimatedGlyph` where it fills on selection. Glyphs are outlined, except inside a filled
|
||||
button, where they take `fill: 1`. The Icons section of `.agents/architecture.md` covers shape roles, the stroke, the
|
||||
fill animation, and when to fill.
|
||||
- Modals dim what they cover with the Material 3 scrim (`ColorScheme.modalScrim`, `scrim` at 32%) and never blur it.
|
||||
Every modal brings its own scrim, including one opened from inside another, so do not make a barrier transparent to
|
||||
keep scrims from stacking.
|
||||
- The keyboard animates `MediaQuery.viewInsets` every frame. Read MediaQuery by aspect (`paddingOf`, `viewInsetsOf`)
|
||||
rather than `MediaQuery.of`, and wrap the page of a new route type in `KeyboardInsetHold`; Keyboard Insets in
|
||||
`.agents/architecture.md` has the reasons.
|
||||
- App bar buttons reach `CommonScaffold` as data (`primaryAction`, `iconActions`, `menuItems`) so it can fold them into
|
||||
its overflow menu; App Bar Actions in `.agents/architecture.md` has the rule.
|
||||
- User-facing text goes through ARB; use `localization` when text changes are non-trivial.
|
||||
- An asynchronous control keeps four things apart: authoritative provider/domain state; display-only state such as a
|
||||
minimum progress duration; tap policy while work or a display hold is active; and failure/disposal cleanup, in
|
||||
`finally` for animations and timers. `CoreStatusButton` and the proxies delay test action are the reference implementations,
|
||||
described under User-Facing Core And Delay Feedback in `.agents/architecture.md`.
|
||||
|
||||
## Corner Radii
|
||||
|
||||
All corner radii come from `lib/common/shape.dart`: a token, a derived value, or a named off-scale value passed to its
|
||||
builders. Never write a bare radius literal at a call site.
|
||||
|
||||
The scale is picked by the component's **shortest side**. A radius that reads as a soft card at 64 logical pixels tall
|
||||
reads as a pill at 24 and as a square at 400, so one radius everywhere is the wrong kind of consistency.
|
||||
|
||||
| Token | Value | Shortest side | Use |
|
||||
| --- | --- | --- | --- |
|
||||
| `none` | 0 | - | square edges, and the flat side of a grouped run |
|
||||
| `xs` | 4 | inset blocks | tiles clipped inside an already rounded surface |
|
||||
| `sm` | 8 | up to 48 | chips, thumbs, swatches, small square tiles |
|
||||
| `md` | 16 | up to 64 | interactive chrome: inputs, menus, buttons, popups, FAB |
|
||||
| `lg` | 20 | 64 to 180 | mid-size cards: dashboard tiles, proxy node cards |
|
||||
| `xl` | 24 | full-bleed | anything spanning the whole width: list rows, grouped runs, group headers |
|
||||
| `xxl` | 28 | over 200 | sheets, dialogs, full-screen containers |
|
||||
| `full` | 1000 | - | pills and circles: buttons, tracks, indicators, progress, avatars |
|
||||
|
||||
- `AppCorner` holds the scale as `double`; `AppRadius` mirrors it as `BorderRadius` with `all`, `top`, and `vertical`
|
||||
builders; `AppShape` mirrors it as `RoundedSuperellipseBorder`, `full` included, plus `circle`, `input`, and the
|
||||
`all`/`top`/`vertical`/`of` builders. Use `AppShape.full` for pills, never `StadiumBorder`; the Corner Radius
|
||||
section of `.agents/rules.md` explains why and lists the superellipse API per layer.
|
||||
- `AppCorner.fit(shortestSide)` snaps to the largest token at or under one third of the shortest side. Use it whenever
|
||||
the size comes from a `LayoutBuilder` or scales with text.
|
||||
- `ThemeData.withAppShapes` in `lib/common/shape.dart` already applies the scale to card, dialog, bottom sheet, snack
|
||||
bar, chip, menu, input, FAB, navigation indicator, progress, and the filled, text, outlined, elevated, and icon
|
||||
button themes. Do not restate those shapes at call sites, and
|
||||
leave `InputDecoration.border` unset so inputs inherit `AppShape.input`.
|
||||
|
||||
`xl` and `lg` exist because one token cannot serve both size classes. A full-bleed surface is wide and short, so its
|
||||
corner must be larger than its height alone suggests or it stops reading as a card; everything full-bleed shares `xl`
|
||||
(`CommonSelectedListItem`, the profiles card, the outer corners of a grouped run in `DecorationListItem`, the proxy
|
||||
group header in `lib/views/proxies/list.dart`) and does not use `fit()`. This is the one deliberate overshoot of the
|
||||
one-third rule; the value stays under half a standard row so nothing clamps and every full-bleed surface renders the
|
||||
same radius. `lg` is for mid-size cards that are not full-bleed: a proxy node card at `ProxyCardType.min` is only about
|
||||
64 tall, which caps its radius at 21, so `xl` would break on a setting the user can change at any time while `lg`
|
||||
clears all three heights and the smallest dashboard tile.
|
||||
|
||||
Outlined inputs use `AppInputBorder`, not `ShapedInputBorder` from `package:material_ui`. The package border subtracts
|
||||
the floating label's notch as a flat band along the top edge, which takes the corner curve with it at any radius from 8
|
||||
up. `AppInputBorder` clips the notch region and paints the full superellipse through it, as Flutter's own
|
||||
`OutlineInputBorder` does by shortening the arc. It needs no `contentPadding` compensation.
|
||||
|
||||
Nested radii are derived, never tokens: concentric corners need `outer = inner + inset`, so name the inset and compute
|
||||
the outer value, as `_cardRadius` in `lib/widgets/popup.dart`, `thumbRadius + _kThumbInset` in `lib/widgets/tab.dart`, and the
|
||||
selection ring in `lib/widgets/palette.dart` do. Adding a token for one of these is how a scale grows without bound.
|
||||
|
||||
A component the scale does not fit keeps its own value instead of growing the scale: name it once in the file and pass
|
||||
it to the `all`/`top`/`vertical` builders, as `_itemShape` in `lib/widgets/sidebar.dart` does with 10, between the
|
||||
chip-sized `sm` and the near-pill `md` on a 40 tall row. Reach for this only once the table and `fit()` are ruled out.
|
||||
|
||||
## Verification
|
||||
|
||||
- A behavior change gets a focused widget test covering rendering states, taps, scrolling, and empty/error states,
|
||||
including the exception path of any async action. Run `flutter analyze` and `flutter test test/widgets/` plus the
|
||||
tests of the touched view.
|
||||
- The scans in `test/lint/` fail on the common UI mistakes and run as part of `flutter test`: an `IconButton` whose
|
||||
icon is an icon needs a `tooltip`; a field that constructs its own controller, notifier, or focus node is released in
|
||||
the same file; a public declaration nothing references is dead. `.agents/rules.md` explains each rule and its
|
||||
allowlist.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- Do not mutate provider/domain state merely to smooth a transition. Keep presentation holds local and let real errors
|
||||
bypass them immediately.
|
||||
- Do not leave loading animations active when callbacks throw.
|
||||
- Do not manually edit generated localization or provider files.
|
||||
@@ -0,0 +1,5 @@
|
||||
# Worktrees
|
||||
|
||||
Claude Code, Codex, and Gemini each create git worktrees. `tool/worktrees.sh list` shows every worktree with its
|
||||
owning tool and whether it holds uncommitted work; `tool/worktrees.sh prune` removes the clean ones and keeps
|
||||
dirty ones unless `--force` is passed. `.claude/worktrees/` is ignored by git; `.claude/settings.json` is tracked.
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Edit|Write|MultiEdit|NotebookEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash \"${CLAUDE_PROJECT_DIR:-.}/tool/check_comment_density.sh\"",
|
||||
"timeout": 15,
|
||||
"statusMessage": "Checking comment density"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/core-platform
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/localization
|
||||
@@ -0,0 +1 @@
|
||||
../../.agents/skills/pre-commit-quality-review
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/ui-work
|
||||
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
[[hooks.PostToolUse]]
|
||||
matcher = "apply_patch|Bash"
|
||||
|
||||
[[hooks.PostToolUse.hooks]]
|
||||
type = "command"
|
||||
command = "bash tool/check_comment_density.sh"
|
||||
timeout = 15
|
||||
statusMessage = "Checking comment density"
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"codegraph": {
|
||||
"type": "stdio",
|
||||
"command": "codegraph",
|
||||
"args": [
|
||||
"serve",
|
||||
"--mcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
# freezed emits lines of trailing spaces, and generated code is never edited by hand.
|
||||
lib/**/generated/** -whitespace
|
||||
@@ -0,0 +1,41 @@
|
||||
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_steps do
|
||||
run "/usr/bin/xattr",
|
||||
args: ["-rd", "com.apple.quarantine", "{{appdir}}/FlClash.app"],
|
||||
writable_paths: ["FlClash.app"],
|
||||
writable_base: :appdir
|
||||
end
|
||||
|
||||
uninstall quit: "com.follow.clash"
|
||||
|
||||
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
|
||||
@@ -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>
|
||||
|
||||
+422
-101
@@ -2,13 +2,320 @@ name: build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
||||
|
||||
env:
|
||||
IS_STABLE: ${{ !contains(github.ref, '-') }}
|
||||
FLUTTER_VERSION: '3.47.4'
|
||||
GO_VERSION: '1.26.4'
|
||||
NDK_VERSION: r28c
|
||||
|
||||
jobs:
|
||||
dart:
|
||||
name: Dart
|
||||
# A private copy pays for its Actions minutes, so it runs the checks only when
|
||||
# started by hand.
|
||||
if: &checks github.event_name == 'workflow_dispatch' || !github.event.repository.private
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
|
||||
- name: Test commit message hook
|
||||
run: bash tool/check_commit_msg_test.sh
|
||||
|
||||
- name: Test comment density hook
|
||||
run: bash tool/check_comment_density_test.sh
|
||||
|
||||
- name: Setup Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: plugins/rust_api/rust
|
||||
cache-directories: .dart_tool/hooks_runner
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: stable
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Check format
|
||||
run: dart format --output=none --set-exit-if-changed lib test tool plugins setup.dart
|
||||
|
||||
- name: Analyze
|
||||
run: flutter analyze --no-fatal-infos
|
||||
|
||||
- name: Skip the Go core build hook
|
||||
run: |
|
||||
yq -i '.hooks.user_defines.setup.build_assets = false' pubspec.yaml
|
||||
|
||||
- name: Verify changelog
|
||||
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')
|
||||
run: dart run tool/changelog.dart verify
|
||||
|
||||
- name: Run tests
|
||||
run: flutter test --reporter expanded --coverage
|
||||
|
||||
- name: Check coverage
|
||||
run: dart run tool/check_coverage.dart coverage/lcov.info 75
|
||||
|
||||
plugins:
|
||||
name: Plugins
|
||||
if: *checks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: stable
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
|
||||
- name: Validate local Flutter packages
|
||||
run: bash tool/check_plugins.sh
|
||||
|
||||
- name: Validate setup hooks package
|
||||
working-directory: plugins/setup/setup_hooks
|
||||
run: |
|
||||
dart pub get
|
||||
dart analyze
|
||||
dart test
|
||||
|
||||
desktop:
|
||||
name: Desktop ${{ matrix.platform }}
|
||||
if: *checks
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux
|
||||
os: ubuntu-latest
|
||||
- platform: windows
|
||||
os: windows-2022
|
||||
- platform: macos
|
||||
os: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache-dependency-path: core/go.sum
|
||||
|
||||
- name: Setup Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: desktop-${{ matrix.platform }}
|
||||
workspaces: |
|
||||
services/helper
|
||||
plugins/rust_api/rust
|
||||
cache-directories: .dart_tool/hooks_runner
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: stable
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
|
||||
- name: Install Linux build dependencies
|
||||
if: matrix.platform == 'linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y ninja-build libgtk-3-dev libayatana-appindicator3-dev libsecret-1-dev
|
||||
|
||||
- name: Setup Xcode
|
||||
if: matrix.platform == 'macos'
|
||||
run: |
|
||||
sudo xcode-select -s /Applications/Xcode_26.3.app || sudo xcode-select -s /Applications/Xcode_26.2.app
|
||||
xcodebuild -version
|
||||
|
||||
- name: Enable git long paths (Windows)
|
||||
if: matrix.platform == 'windows'
|
||||
run: git config --global core.longpaths true
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
# Compiles the desktop runners and the native plugin code that only the
|
||||
# tagged release matrix built before; the bundle itself is discarded.
|
||||
- name: Build debug bundle
|
||||
run: flutter build ${{ matrix.platform }} --debug
|
||||
|
||||
go:
|
||||
name: Go core
|
||||
if: *checks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache-dependency-path: core/go.sum
|
||||
|
||||
- name: Validate Go core wrapper
|
||||
working-directory: core
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
run: |
|
||||
if ! compgen -G '*_test.go' > /dev/null; then
|
||||
echo 'No Go tests found in core/; the test step would pass vacuously.' >&2
|
||||
exit 1
|
||||
fi
|
||||
test -z "$(gofmt -l ./*.go ./platform ./tun)" || {
|
||||
gofmt -l ./*.go ./platform ./tun >&2
|
||||
exit 1
|
||||
}
|
||||
go test -count=1 .
|
||||
go vet .
|
||||
GOOS=linux go vet ./platform
|
||||
|
||||
- name: Setup NDK
|
||||
uses: nttld/setup-ndk@v1
|
||||
id: ndk
|
||||
with:
|
||||
ndk-version: ${{ env.NDK_VERSION }}
|
||||
|
||||
- name: Validate the Android core library
|
||||
working-directory: core
|
||||
env:
|
||||
ANDROID_NDK_HOME: ${{ steps.ndk.outputs.ndk-path }}
|
||||
run: |
|
||||
# Nothing else compiles the `android && cgo` files before a `v*` tag.
|
||||
CC="$(echo "$ANDROID_NDK_HOME"/toolchains/llvm/prebuilt/*/bin/aarch64-linux-android21-clang)"
|
||||
test -x "$CC" || { echo "NDK clang not found at $CC" >&2; exit 1; }
|
||||
tags="$(sed -n 's/^tags: *//p' ../build_config.yaml)"
|
||||
test -n "$tags" || { echo 'Could not read tags from build_config.yaml' >&2; exit 1; }
|
||||
export CGO_ENABLED=1 GOOS=android GOARCH=arm64 CC
|
||||
go vet "-tags=$tags" . ./tun ./platform
|
||||
|
||||
android:
|
||||
name: Android unit tests
|
||||
if: *checks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- 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: Setup NDK
|
||||
uses: nttld/setup-ndk@v1
|
||||
id: ndk
|
||||
with:
|
||||
ndk-version: ${{ env.NDK_VERSION }}
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '17'
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
|
||||
- name: Run Android unit tests
|
||||
working-directory: android
|
||||
env:
|
||||
ANDROID_NDK_HOME: ${{ steps.ndk.outputs.ndk-path }}
|
||||
run: |
|
||||
{
|
||||
echo "flutter.sdk=$FLUTTER_ROOT"
|
||||
echo "sdk.dir=$ANDROID_HOME"
|
||||
} > local.properties
|
||||
wrapper="$FLUTTER_ROOT/bin/cache/artifacts/gradle_wrapper"
|
||||
test -d "$wrapper" || flutter precache
|
||||
cp -R "$wrapper/." .
|
||||
chmod +x gradlew
|
||||
# `:core` only calls System.loadLibrary; the excluded Flutter tasks would run
|
||||
# the native build hooks for every debug ABI, which no JVM unit test consumes.
|
||||
./gradlew :common:testDebugUnitTest :service:testDebugUnitTest :app:testDebugUnitTest \
|
||||
-x :app:compileFlutterBuildDebug -x :app:copyFlutterAssetsDebug
|
||||
|
||||
rust:
|
||||
name: Rust
|
||||
if: *checks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: |
|
||||
services/helper
|
||||
plugins/rust_api/rust
|
||||
|
||||
- name: Check format
|
||||
run: |
|
||||
cargo fmt --manifest-path services/helper/Cargo.toml -- --check
|
||||
cargo fmt --manifest-path plugins/rust_api/rust/Cargo.toml -- --check
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
cargo test --manifest-path services/helper/Cargo.toml
|
||||
cargo test --manifest-path plugins/rust_api/rust/Cargo.toml
|
||||
|
||||
windows-helper-test:
|
||||
name: Windows helper test
|
||||
if: *checks
|
||||
runs-on: windows-2022
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: services/helper
|
||||
|
||||
- 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:
|
||||
# Releases publish where the release links point: the public repository.
|
||||
if: github.repository == 'chen08209/FlClash' && github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
|
||||
needs: [ dart, plugins, go, android, rust, windows-helper-test ]
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -27,9 +334,9 @@ jobs:
|
||||
- platform: macos
|
||||
os: macos-latest
|
||||
arch: arm64
|
||||
# - platform: windows
|
||||
# os: windows-11-arm
|
||||
# arch: arm64
|
||||
- platform: windows
|
||||
os: windows-11-arm
|
||||
arch: arm64
|
||||
- platform: linux
|
||||
os: ubuntu-24.04-arm
|
||||
arch: arm64
|
||||
@@ -37,6 +344,7 @@ jobs:
|
||||
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
|
||||
@@ -53,39 +361,71 @@ jobs:
|
||||
run: |
|
||||
echo "${{ secrets.KEYSTORE }}" | base64 --decode > android/app/keystore.jks
|
||||
echo "${{ secrets.SERVICE_JSON }}" | base64 --decode > android/app/google-services.json
|
||||
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 "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: '1.24.0'
|
||||
cache-dependency-path: |
|
||||
core/go.sum
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache-dependency-path: core/go.sum
|
||||
|
||||
- name: Setup Flutter
|
||||
if: ${{ !(startsWith(matrix.os, 'windows-11-arm') || startsWith(matrix.os, 'ubuntu-24.04-arm')) }}
|
||||
uses: subosito/flutter-action@v2
|
||||
- name: Setup Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
channel: stable
|
||||
flutter-version: 3.35.7
|
||||
cache: true
|
||||
- name: Setup Flutter With Other
|
||||
if: startsWith(matrix.os, 'windows-11-arm') || startsWith(matrix.os, 'ubuntu-24.04-arm')
|
||||
key: ${{ matrix.platform }}-${{ matrix.arch || 'all' }}
|
||||
workspaces: services/helper
|
||||
cache-directories: .dart_tool/hooks_runner
|
||||
|
||||
- name: Setup Gradle
|
||||
if: startsWith(matrix.platform,'android')
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
|
||||
- name: Enable git long paths (Windows)
|
||||
if: startsWith(matrix.os, 'windows')
|
||||
run: git config --global core.longpaths true
|
||||
|
||||
# Stable publishes no Windows or Linux arm64 archive; master makes the action
|
||||
# clone the SDK at the pinned tag and let it fetch the host's own Dart SDK.
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: master
|
||||
flutter-version: 3.35.7
|
||||
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
|
||||
id: build-ndk
|
||||
with:
|
||||
ndk-version: ${{ env.NDK_VERSION }}
|
||||
add-to-path: true
|
||||
link-to-sdk: true
|
||||
|
||||
- name: Get Flutter Dependency
|
||||
run: |
|
||||
flutter --version
|
||||
flutter pub get
|
||||
|
||||
# See .agents/flutter-upgrade.md before bumping FLUTTER_VERSION.
|
||||
- name: Disable Impeller on Intel macOS
|
||||
if: matrix.platform == 'macos' && matrix.arch == 'amd64'
|
||||
run: plutil -replace FLTEnableImpeller -bool NO macos/Runner/Info.plist
|
||||
|
||||
- name: Setup
|
||||
run: dart setup.dart ${{ matrix.platform }} ${{ matrix.arch && format('--arch {0}', matrix.arch) }} ${{ env.IS_STABLE == 'true' && '--env stable' || '' }}
|
||||
env:
|
||||
ANDROID_NDK_HOME: ${{ steps.build-ndk.outputs.ndk-path }}
|
||||
run: dart setup.dart ${{ matrix.platform }} ${{ env.IS_STABLE == 'true' && '--env stable' || '' }} -v
|
||||
|
||||
- name: Upload
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -94,63 +434,9 @@ jobs:
|
||||
path: ./dist
|
||||
overwrite: true
|
||||
|
||||
changelog:
|
||||
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: |
|
||||
last_ver=$(grep -m1 '^## ' CHANGELOG.md 2>/dev/null | sed 's/^## //')
|
||||
|
||||
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
|
||||
[ -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
|
||||
echo "Commit pushing"
|
||||
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
|
||||
echo "Push succeeded"
|
||||
else
|
||||
echo "Push failed"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
upload:
|
||||
permissions: write-all
|
||||
permissions:
|
||||
contents: write
|
||||
needs: [ build ]
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
@@ -174,26 +460,35 @@ jobs:
|
||||
pattern: artifact-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Generate release.md
|
||||
- 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: Skip native build hooks
|
||||
run: |
|
||||
tags=($(git tag --merged HEAD --sort=-creatordate))
|
||||
preTag=$(curl -s "https://api.github.com/repos/chen08209/FlClash/releases/latest" | \
|
||||
sed -nE 's/.*"tag_name": "([^"]+)".*/\1/p')
|
||||
|
||||
[ -z "$preTag" ] && preTag=""
|
||||
|
||||
out="release.md" > "$out"
|
||||
|
||||
for i in "${!tags[@]}"; do
|
||||
curr="${tags[i]}"
|
||||
[[ "$curr" == "$preTag" ]] && break
|
||||
|
||||
prev="${tags[i+1]}"
|
||||
range="${prev:+$prev..}$curr"
|
||||
|
||||
git log --no-merges --pretty=format:"%B" "$range" | \
|
||||
awk '!/Update changelog/ && NF {print "- " $0 "\n"}' >> "$out"
|
||||
done
|
||||
yq -i '.hooks.user_defines.setup.build_assets = false | .hooks.user_defines.rust_api.build_assets = false' pubspec.yaml
|
||||
|
||||
- name: Generate release notes
|
||||
shell: bash
|
||||
env:
|
||||
IS_STABLE: ${{ env.IS_STABLE }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
target=()
|
||||
if [[ "$IS_STABLE" == 'true' ]]; then
|
||||
target=(--tag "$TAG")
|
||||
else
|
||||
dart run tool/changelog.dart build --unreleased
|
||||
fi
|
||||
dart run tool/changelog.dart render release "${target[@]}" --out release.md
|
||||
dart run tool/changelog.dart render telegram "${target[@]}" --out telegram.md
|
||||
|
||||
- name: Push to telegram
|
||||
env:
|
||||
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
@@ -210,12 +505,10 @@ jobs:
|
||||
sed "s|VERSION|$version|g" ./.github/release_template.md >> release.md
|
||||
|
||||
- name: Generate sha256
|
||||
if: env.IS_STABLE == 'true'
|
||||
if: ${{ env.IS_STABLE == 'true' }}
|
||||
run: |
|
||||
cd ./dist
|
||||
for file in $(find . -type f -not -name "*.sha256"); do
|
||||
sha256sum "$file" > "${file}.sha256"
|
||||
done
|
||||
sha256sum ./* > SHA256SUMS
|
||||
|
||||
- name: Release
|
||||
if: ${{ env.IS_STABLE == 'true' }}
|
||||
@@ -224,6 +517,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: |
|
||||
@@ -240,9 +562,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/
|
||||
|
||||
|
||||
+13
-4
@@ -41,9 +41,12 @@ app.*.symbols
|
||||
# Obfuscation related
|
||||
app.*.map.json
|
||||
|
||||
#AI generated
|
||||
CLAUDE.md
|
||||
/.claude
|
||||
# Claude Code - ignore user-specific settings only
|
||||
.claude/settings.local.json
|
||||
.claude/worktrees/
|
||||
|
||||
# Documentation (generated/planning artifacts)
|
||||
docs/
|
||||
|
||||
|
||||
# Android Studio will place build artifacts here
|
||||
@@ -63,8 +66,14 @@ CLAUDE.md
|
||||
/android/app/src/main/jniLibs/
|
||||
/services/helper/target
|
||||
/macos/**/Package.resolved
|
||||
/macos/build/
|
||||
/env.json
|
||||
/core_sha256.json
|
||||
/plugins/rust_api/.claude/
|
||||
/issue-triage/
|
||||
devtools_options.yaml
|
||||
|
||||
# FVM Version Cache
|
||||
.fvm/
|
||||
.fvmrc
|
||||
.fvmrc
|
||||
/coverage/lcov.info
|
||||
|
||||
-10
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"codegraph": {
|
||||
"type": "stdio",
|
||||
"command": "codegraph",
|
||||
"args": [
|
||||
"serve",
|
||||
"--mcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
repos:
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: dart-format
|
||||
name: dart format
|
||||
entry: dart format
|
||||
language: system
|
||||
files: \.dart$
|
||||
stages: [pre-commit]
|
||||
- id: comment-density
|
||||
name: comment density
|
||||
entry: tool/check_comment_density.sh
|
||||
language: system
|
||||
files: \.(dart|kt|kts|swift|go|rs|java|cpp|cc|h|hpp|gradle|ya?ml)$
|
||||
stages: [pre-commit]
|
||||
- id: flutter-analyze
|
||||
name: flutter analyze (pre-push)
|
||||
entry: flutter analyze --no-fatal-infos
|
||||
language: system
|
||||
pass_filenames: false
|
||||
stages: [pre-push]
|
||||
- id: commit-msg-format
|
||||
name: conventional commit message
|
||||
entry: tool/check_commit_msg.sh
|
||||
language: system
|
||||
stages: [commit-msg]
|
||||
@@ -0,0 +1,7 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="main.dart (safe mode)" type="FlutterRunConfigurationType" factoryName="Flutter">
|
||||
<option name="additionalArgs" value="--dart-define-from-file env.json --dart-define=SAFE_MODE=true" />
|
||||
<option name="filePath" value="$PROJECT_DIR$/lib/main.dart" />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -0,0 +1,24 @@
|
||||
[
|
||||
{
|
||||
"label": "Flutter App (With Env)",
|
||||
"adapter": "Dart",
|
||||
"type": "flutter",
|
||||
"request": "launch",
|
||||
"program": "lib/main.dart",
|
||||
"cwd": "$ZED_WORKTREE_ROOT",
|
||||
"toolArgs": ["--dart-define-from-file", "env.json"],
|
||||
},
|
||||
{
|
||||
"label": "Flutter App (Safe Mode)",
|
||||
"adapter": "Dart",
|
||||
"type": "flutter",
|
||||
"request": "launch",
|
||||
"program": "lib/main.dart",
|
||||
"cwd": "$ZED_WORKTREE_ROOT",
|
||||
"toolArgs": [
|
||||
"--dart-define-from-file",
|
||||
"env.json",
|
||||
"--dart-define=SAFE_MODE=true",
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"lsp": {
|
||||
"gopls": {
|
||||
"binary": {
|
||||
"env": {
|
||||
"CGO_ENABLED": "0",
|
||||
// "GOOS": "android",
|
||||
// "GOARCH": "arm64",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
[
|
||||
{
|
||||
"label": "flutter run (With Env)",
|
||||
"command": "flutter",
|
||||
"args": ["run", "--dart-define-from-file", "env.json"],
|
||||
"cwd": "$ZED_WORKTREE_ROOT",
|
||||
"use_new_terminal": true,
|
||||
},
|
||||
{
|
||||
"label": "flutter run (Safe Mode)",
|
||||
"command": "flutter",
|
||||
"args": [
|
||||
"run",
|
||||
"--dart-define-from-file",
|
||||
"env.json",
|
||||
"--dart-define=SAFE_MODE=true",
|
||||
],
|
||||
"cwd": "$ZED_WORKTREE_ROOT",
|
||||
"use_new_terminal": true,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,70 @@
|
||||
# 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/worktrees.md](.agents/worktrees.md): worktree hygiene across Claude Code, Codex, and Gemini.
|
||||
- [.agents/flutter-upgrade.md](.agents/flutter-upgrade.md): SDK-pinned workarounds to recheck before changing the
|
||||
Flutter version.
|
||||
- [.agents/skills.md](.agents/skills.md): index of repo-scoped skills in `.agents/skills/`.
|
||||
|
||||
## Highest Priority Rules
|
||||
|
||||
- When the user explicitly requests a scoped, low-risk change, inspect the relevant context and implement it directly.
|
||||
Do not require brainstorming, design documents, implementation plans, multiple-option proposals, or repeated confirmation.
|
||||
Ask only when material ambiguity, destructive impact, additional authority, or scope expansion could change the result.
|
||||
- The request sets the scope. A pre-existing bug, cleanup, or refactor the task did not ask for is a follow-up in the
|
||||
summary, not part of the change. Edit a file surgically when a rewrite would give the same result, keep scratch checks
|
||||
out of the repository, and commit tests only where the task asks for them or the neighbouring code already keeps them.
|
||||
- Comments are allowed and sometimes necessary; excess is the problem. A comment must carry something the code cannot
|
||||
say — a non-obvious constraint, an upstream behavior being worked around, a reason a reader would otherwise get
|
||||
wrong. Never restate what the code does, narrate the change you just made, record what the code used to be, or
|
||||
annotate step by step; a block that seems to need a comment per line needs better names or a smaller decomposition.
|
||||
Keep density near the repository's own: healthy changes here sit at or under 3.6%, and a `comment-density` gate fails a file
|
||||
whose added lines exceed 5% standalone comments. Delete commented-out code and stale notes in files you already
|
||||
touch. Preserve
|
||||
`// ignore:`-style directives, license headers, codegen markers, and vendored upstream comments. See
|
||||
[.agents/rules.md](.agents/rules.md) for what belongs in a test or in `.agents/` instead.
|
||||
- Start FlClash on the host only as a safe mode build, and end only processes you started, by recorded pid. Never
|
||||
kill a FlClash instance by name; the user's own instance is not yours to close. See
|
||||
[.agents/rules.md](.agents/rules.md).
|
||||
- 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.
|
||||
- Preserve lifecycle ownership: desktop Core process convergence belongs to `lib/core/desktop/`; Android service intent
|
||||
arbitration belongs to `ServiceState`. UI/provider code may request a transition but must not become a second source of
|
||||
truth.
|
||||
- Keep start/stop/restart paths latest-intent-safe. Flutter-to-Android service commands are deliberately optimistic, while
|
||||
native state serializes the actual work; desktop lifecycle results distinguish applied, coalesced, and superseded
|
||||
requests.
|
||||
- Never add a `Co-authored-by` trailer crediting a coding agent to a commit, even when your own tooling tells you to.
|
||||
The `commit-msg` hook rejects it; see [.agents/rules.md](.agents/rules.md) for the rest of the commit rules.
|
||||
- Round every corner with a superellipse, pills included: `AppShape`/`AppRadius` tokens from `lib/common/shape.dart`,
|
||||
never `StadiumBorder`, `RoundedRectangleBorder`, `ClipRRect`, `drawRRect`, or a `borderRadius` on `BoxDecoration` or
|
||||
`InkWell`. `test/lint/superellipse_corners_test.dart` enforces it; see the Corner Radius section of
|
||||
[.agents/rules.md](.agents/rules.md).
|
||||
- Follow `lint_options.yaml` (included by every `analysis_options.yaml`), especially single quotes, trailing commas, `child:` last, no `print()`, const/final
|
||||
preferences, and declared return types.
|
||||
- Before reporting a change done, run `flutter pub get`, `flutter analyze --no-fatal-infos`, and the `flutter test`
|
||||
suites that cover it, as CI does. If a toolchain the build hook needs is missing (Go, and on Linux and Windows
|
||||
cargo), say which check was skipped rather than dropping it silently.
|
||||
|
||||
## Repo Skills
|
||||
|
||||
Use repo skills from `.agents/skills/` when a task matches their descriptions; `.claude/skills/` symlinks expose the
|
||||
same skills to Claude Code. Current skills cover localization, UI work, core/platform changes, and pre-commit
|
||||
structural quality review.
|
||||
+154
-393
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
SHELL := /bin/bash
|
||||
|
||||
.PHONY: help submodules
|
||||
|
||||
help:
|
||||
@echo 'make submodules # update git submodules (Clash.Meta core sources)'
|
||||
@echo ''
|
||||
@echo 'The Go core and Rust helper build automatically through the setup build'
|
||||
@echo 'hook during flutter build; see .agents/commands.md.'
|
||||
|
||||
submodules:
|
||||
git submodule update --init --recursive
|
||||
@@ -12,14 +12,11 @@
|
||||
|
||||
A multi-platform proxy client based on ClashMeta, simple and easy to use, open-source and ad-free.
|
||||
|
||||
on Desktop:
|
||||
<p style="text-align: center;">
|
||||
<img alt="desktop" src="snapshots/desktop.gif">
|
||||
</p>
|
||||
|
||||
on Mobile:
|
||||
<p style="text-align: center;">
|
||||
<img alt="mobile" src="snapshots/mobile.gif">
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="snapshots/preview-dark.png">
|
||||
<img alt="FlClash on desktop and mobile" src="snapshots/preview.png" width="90%">
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
## Features
|
||||
@@ -42,7 +39,6 @@ on Mobile:
|
||||
|
||||
```bash
|
||||
sudo apt-get install libayatana-appindicator3-dev
|
||||
sudo apt-get install libkeybinder-3.0-dev
|
||||
```
|
||||
|
||||
### Android
|
||||
@@ -61,6 +57,13 @@ Support the following actions
|
||||
|
||||
<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 +77,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
|
||||
```
|
||||
|
||||
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 +132,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>
|
||||
|
||||
+25
-17
@@ -12,14 +12,11 @@
|
||||
|
||||
基于ClashMeta的多平台代理客户端,简单易用,开源无广告。
|
||||
|
||||
on Desktop:
|
||||
<p style="text-align: center;">
|
||||
<img alt="desktop" src="snapshots/desktop.gif">
|
||||
</p>
|
||||
|
||||
on Mobile:
|
||||
<p style="text-align: center;">
|
||||
<img alt="mobile" src="snapshots/mobile.gif">
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="snapshots/preview-dark.png">
|
||||
<img alt="FlClash on desktop and mobile" src="snapshots/preview.png" width="90%">
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
## Features
|
||||
@@ -42,7 +39,6 @@ on Mobile:
|
||||
|
||||
```bash
|
||||
sudo apt-get install libayatana-appindicator3-dev
|
||||
sudo apt-get install libkeybinder-3.0-dev
|
||||
```
|
||||
|
||||
### Android
|
||||
@@ -61,6 +57,13 @@ on Mobile:
|
||||
|
||||
<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 +84,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
|
||||
```
|
||||
|
||||
3. 运行构建脚本
|
||||
|
||||
```bash
|
||||
dart .\setup.dart linux --arch <arm64 | amd64>
|
||||
dart setup.dart linux
|
||||
```
|
||||
|
||||
- macOS
|
||||
@@ -113,10 +121,10 @@ on Mobile:
|
||||
2. 运行构建脚本
|
||||
|
||||
```bash
|
||||
dart .\setup.dart macos --arch <arm64 | amd64>
|
||||
dart setup.dart macos
|
||||
```
|
||||
|
||||
## Star History
|
||||
## Star
|
||||
|
||||
支持开发者的最简单方式是点击页面顶部的星标(⭐)。
|
||||
|
||||
@@ -124,4 +132,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>
|
||||
|
||||
+11
-5
@@ -1,10 +1,16 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
include: lint_options.yaml
|
||||
|
||||
analyzer:
|
||||
exclude:
|
||||
- build/**
|
||||
- lib/l10n/intl/**
|
||||
- lib/**/generated/**
|
||||
- plugins/**
|
||||
- android/**
|
||||
- ios/**
|
||||
- web/**
|
||||
- windows/**
|
||||
- macos/**
|
||||
- linux/**
|
||||
errors:
|
||||
invalid_annotation_target: ignore
|
||||
|
||||
linter:
|
||||
rules:
|
||||
prefer_single_quotes: true
|
||||
@@ -1,36 +1,35 @@
|
||||
import com.google.firebase.crashlytics.buildtools.gradle.CrashlyticsExtension
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
id("com.google.gms.google-services")
|
||||
id("com.google.firebase.crashlytics")
|
||||
}
|
||||
|
||||
val localPropertiesFile = rootProject.file("local.properties")
|
||||
val localProperties = Properties().apply {
|
||||
val localPropertiesFile = rootProject.file("local.properties")
|
||||
if (localPropertiesFile.exists()) {
|
||||
localPropertiesFile.inputStream().use { load(it) }
|
||||
}
|
||||
}
|
||||
|
||||
val mStoreFile: File = file("keystore.jks")
|
||||
val mStorePassword: String? = localProperties.getProperty("storePassword")
|
||||
val mKeyAlias: String? = localProperties.getProperty("keyAlias")
|
||||
val mKeyPassword: String? = localProperties.getProperty("keyPassword")
|
||||
val isRelease =
|
||||
mStoreFile.exists() && mStorePassword != null && mKeyAlias != null && mKeyPassword != null
|
||||
|
||||
val releaseStoreFile = file("keystore.jks")
|
||||
val releaseStorePassword = localProperties.getProperty("storePassword")
|
||||
val releaseKeyAlias = localProperties.getProperty("keyAlias")
|
||||
val releaseKeyPassword = localProperties.getProperty("keyPassword")
|
||||
val hasReleaseSigning = releaseStoreFile.exists() &&
|
||||
releaseStorePassword != null &&
|
||||
releaseKeyAlias != null &&
|
||||
releaseKeyPassword != null
|
||||
|
||||
android {
|
||||
namespace = "com.follow.clash"
|
||||
compileSdk = libs.versions.compileSdk.get().toInt()
|
||||
ndkVersion = libs.versions.ndkVersion.get()
|
||||
|
||||
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
@@ -45,12 +44,12 @@ android {
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
if (isRelease) {
|
||||
if (hasReleaseSigning) {
|
||||
create("release") {
|
||||
storeFile = mStoreFile
|
||||
storePassword = mStorePassword
|
||||
keyAlias = mKeyAlias
|
||||
keyPassword = mKeyPassword
|
||||
storeFile = releaseStoreFile
|
||||
storePassword = releaseStorePassword
|
||||
keyAlias = releaseKeyAlias
|
||||
keyPassword = releaseKeyPassword
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,7 +69,7 @@ android {
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
if (isRelease) {
|
||||
if (hasReleaseSigning) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
} else {
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
@@ -78,10 +77,20 @@ android {
|
||||
}
|
||||
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
|
||||
configure<CrashlyticsExtension> {
|
||||
nativeSymbolUploadEnabled = hasReleaseSigning
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
// Unit tests live under android/tests/ instead of each module's src/test.
|
||||
getByName("test").java.setSrcDirs(listOf("../tests/app"))
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
@@ -94,10 +103,17 @@ flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
// The Crashlytics plugin finalizes R8 with the mapping upload but leaves the native symbol upload to the caller.
|
||||
if (hasReleaseSigning) {
|
||||
tasks.matching { it.name == "assembleRelease" || it.name == "bundleRelease" }.configureEach {
|
||||
finalizedBy("uploadCrashlyticsSymbolFileRelease")
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":service"))
|
||||
implementation(project(":common"))
|
||||
implementation(project(":core"))
|
||||
implementation(libs.core.splashscreen)
|
||||
implementation(libs.gson)
|
||||
implementation(libs.smali.dexlib2) {
|
||||
@@ -106,4 +122,6 @@ dependencies {
|
||||
implementation(platform(libs.firebase.bom))
|
||||
implementation(libs.firebase.crashlytics.ndk)
|
||||
implementation(libs.firebase.analytics)
|
||||
}
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
}
|
||||
|
||||
Vendored
+2
-2
@@ -1,4 +1,4 @@
|
||||
|
||||
-keep class com.follow.clash.models.**{ *; }
|
||||
-keep class com.follow.clash.models.** { *; }
|
||||
|
||||
-keep class com.follow.clash.service.models.**{ *; }
|
||||
-keep class com.follow.clash.service.models.** { *; }
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<application
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="FlClash Debug"
|
||||
|
||||
@@ -11,24 +11,30 @@
|
||||
<uses-feature
|
||||
android:name="android.software.leanback"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.wifi"
|
||||
android:required="false" />
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
||||
<uses-permission
|
||||
android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||
tools:ignore="QueryAllPackagesPermission" />
|
||||
<uses-permission android:name="com.android.permission.GET_INSTALLED_APPS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
|
||||
|
||||
<application
|
||||
android:name=".Application"
|
||||
android:name=".FlClashApplication"
|
||||
android:banner="@mipmap/ic_banner"
|
||||
android:hardwareAccelerated="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="FlClash">
|
||||
android:label="@string/app_name">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
@@ -65,7 +71,7 @@
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".TempActivity"
|
||||
android:name=".QuickActionActivity"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="true"
|
||||
android:theme="@style/TransparentTheme">
|
||||
@@ -87,7 +93,7 @@
|
||||
android:name=".TileService"
|
||||
android:exported="true"
|
||||
android:icon="@drawable/ic"
|
||||
android:label="FlClash"
|
||||
android:label="@string/app_name"
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||
@@ -98,13 +104,13 @@
|
||||
</service>
|
||||
|
||||
<receiver
|
||||
android:name=".BroadcastReceiver"
|
||||
android:name=".ServiceBroadcastReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="true"
|
||||
android:permission="${applicationId}.permission.RECEIVE_BROADCASTS">
|
||||
<intent-filter>
|
||||
<action android:name="${applicationId}.intent.action.SERVICE_CREATED" />
|
||||
<action android:name="${applicationId}.intent.action.SERVICE_DESTROYED" />
|
||||
<action android:name="${applicationId}.intent.action.VPN_START_REQUESTED" />
|
||||
<action android:name="${applicationId}.intent.action.VPN_REVOKED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
@@ -112,4 +118,4 @@
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
</application>
|
||||
</manifest>
|
||||
</manifest>
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
package com.follow.clash
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.follow.clash.common.BroadcastAction
|
||||
import com.follow.clash.common.GlobalState
|
||||
import com.follow.clash.common.action
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class BroadcastReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
when (intent?.action) {
|
||||
BroadcastAction.SERVICE_CREATED.action -> {
|
||||
GlobalState.log("Receiver service created")
|
||||
GlobalState.launch {
|
||||
State.handleStartServiceAction()
|
||||
}
|
||||
}
|
||||
|
||||
BroadcastAction.SERVICE_DESTROYED.action -> {
|
||||
GlobalState.log("Receiver service destroyed")
|
||||
GlobalState.launch {
|
||||
State.handleStopServiceAction()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,37 +17,32 @@ import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
private const val ICON_TTL_DAYS = 1L
|
||||
|
||||
val Application.sharedState: SharedState
|
||||
get() {
|
||||
try {
|
||||
val sp = getSharedPreferences("FlutterSharedPreferences", MODE_PRIVATE)
|
||||
val res = sp.getString("flutter.sharedState", "")
|
||||
return Gson().fromJson(res, SharedState::class.java)
|
||||
} catch (_: Exception) {
|
||||
return SharedState()
|
||||
}
|
||||
get() = try {
|
||||
val preferences = getSharedPreferences("FlutterSharedPreferences", MODE_PRIVATE)
|
||||
val json = preferences.getString("flutter.sharedState", null)
|
||||
Gson().fromJson(json, SharedState::class.java) ?: SharedState()
|
||||
} catch (_: Exception) {
|
||||
SharedState()
|
||||
}
|
||||
|
||||
|
||||
private var lastToast: Toast? = null
|
||||
|
||||
fun Application.showToast(text: String?) {
|
||||
if (text.isNullOrEmpty()) return
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
lastToast?.cancel()
|
||||
lastToast = Toast.makeText(this, text, Toast.LENGTH_LONG).apply {
|
||||
show()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
suspend fun PackageManager.getPackageIconPath(packageName: String): String =
|
||||
@@ -55,8 +50,7 @@ suspend fun PackageManager.getPackageIconPath(packageName: String): String =
|
||||
val cacheDir = GlobalState.application.cacheDir
|
||||
val iconDir = File(cacheDir, "icons").apply { mkdirs() }
|
||||
return@withContext try {
|
||||
val pkgInfo = getPackageInfo(packageName, 0)
|
||||
val lastUpdateTime = pkgInfo.lastUpdateTime
|
||||
val lastUpdateTime = getPackageInfo(packageName, 0).lastUpdateTime
|
||||
val iconFile = File(iconDir, "${packageName}_${lastUpdateTime}.webp")
|
||||
if (iconFile.exists() && !isExpired(iconFile)) {
|
||||
return@withContext iconFile.absolutePath
|
||||
@@ -80,14 +74,11 @@ private suspend fun saveDrawableToFile(drawable: Drawable, file: File) {
|
||||
drawable.toBitmap(width = 128, height = 128)
|
||||
}
|
||||
try {
|
||||
val format = when {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> {
|
||||
Bitmap.CompressFormat.WEBP_LOSSY
|
||||
}
|
||||
|
||||
else -> {
|
||||
Bitmap.CompressFormat.WEBP
|
||||
}
|
||||
@Suppress("DEPRECATION")
|
||||
val format = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
Bitmap.CompressFormat.WEBP_LOSSY
|
||||
} else {
|
||||
Bitmap.CompressFormat.WEBP
|
||||
}
|
||||
FileOutputStream(file).use { fos ->
|
||||
bitmap.compress(format, 90, fos)
|
||||
@@ -103,48 +94,12 @@ private fun isExpired(file: File): Boolean {
|
||||
return age > TimeUnit.DAYS.toMillis(ICON_TTL_DAYS)
|
||||
}
|
||||
|
||||
suspend fun <T> MethodChannel.awaitResult(
|
||||
method: String, arguments: Any? = null
|
||||
): T? = withContext(Dispatchers.Main) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
invokeMethod(method, arguments, object : MethodChannel.Result {
|
||||
override fun success(result: Any?) {
|
||||
@Suppress("UNCHECKED_CAST") continuation.resume(result as T?)
|
||||
}
|
||||
|
||||
override fun error(code: String, message: String?, details: Any?) {
|
||||
continuation.resume(null)
|
||||
}
|
||||
|
||||
override fun notImplemented() {
|
||||
continuation.resume(null)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <reified T : FlutterPlugin> FlutterEngine.plugin(): T? {
|
||||
return plugins.get(T::class.java) as T?
|
||||
}
|
||||
|
||||
fun <T> MethodChannel.invokeMethodOnMainThread(
|
||||
method: String, arguments: Any? = null, callback: ((Result<T>) -> Unit)? = null
|
||||
) {
|
||||
fun MethodChannel.invokeMethodOnMainThread(method: String, arguments: Any? = null) {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
invokeMethod(method, arguments, object : MethodChannel.Result {
|
||||
override fun success(result: Any?) {
|
||||
@Suppress("UNCHECKED_CAST") callback?.invoke(Result.success(result as T))
|
||||
}
|
||||
|
||||
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
|
||||
val exception = Exception("MethodChannel error: $errorCode - $errorMessage")
|
||||
callback?.invoke(Result.failure(exception))
|
||||
}
|
||||
|
||||
override fun notImplemented() {
|
||||
val exception = NotImplementedError("Method not implemented: $method")
|
||||
callback?.invoke(Result.failure(exception))
|
||||
}
|
||||
})
|
||||
invokeMethod(method, arguments)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -4,8 +4,7 @@ import android.app.Application
|
||||
import android.content.Context
|
||||
import com.follow.clash.common.GlobalState
|
||||
|
||||
class Application : Application() {
|
||||
|
||||
class FlClashApplication : Application() {
|
||||
override fun attachBaseContext(base: Context?) {
|
||||
super.attachBaseContext(base)
|
||||
GlobalState.init(this)
|
||||
@@ -1,37 +1,22 @@
|
||||
package com.follow.clash
|
||||
|
||||
import android.os.Bundle
|
||||
import com.follow.clash.common.GlobalState
|
||||
import com.follow.clash.plugins.AppPlugin
|
||||
import com.follow.clash.plugins.ServicePlugin
|
||||
import com.follow.clash.plugins.TilePlugin
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MainActivity : FlutterActivity(),
|
||||
CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
}
|
||||
|
||||
class MainActivity : FlutterActivity() {
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
flutterEngine.plugins.add(AppPlugin())
|
||||
flutterEngine.plugins.add(ServicePlugin())
|
||||
flutterEngine.plugins.add(TilePlugin())
|
||||
State.flutterEngine = flutterEngine
|
||||
ServiceState.attachFlutterEngine(flutterEngine)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
GlobalState.launch {
|
||||
Service.setEventListener(null)
|
||||
}
|
||||
State.flutterEngine = null
|
||||
flutterEngine?.let(ServiceState::detachFlutterEngine)
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.follow.clash
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Bundle
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import com.follow.clash.common.GlobalState
|
||||
import com.follow.clash.common.QuickAction
|
||||
import com.follow.clash.common.action
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class QuickActionActivity : Activity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
when (intent.action) {
|
||||
QuickAction.START.action -> GlobalState.launch { ServiceState.handleStartAction() }
|
||||
QuickAction.STOP.action -> GlobalState.launch { ServiceState.handleStopAction() }
|
||||
QuickAction.TOGGLE.action -> {
|
||||
ShortcutManagerCompat.reportShortcutUsed(this, SHORTCUT_ID)
|
||||
GlobalState.launch { ServiceState.handleToggleAction() }
|
||||
}
|
||||
}
|
||||
finish()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SHORTCUT_ID = "toggle"
|
||||
}
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
package com.follow.clash
|
||||
|
||||
import com.follow.clash.common.GlobalState
|
||||
import com.follow.clash.common.ServiceDelegate
|
||||
import com.follow.clash.common.formatString
|
||||
import com.follow.clash.common.intent
|
||||
import com.follow.clash.service.IAckInterface
|
||||
import com.follow.clash.service.ICallbackInterface
|
||||
import com.follow.clash.service.IEventInterface
|
||||
import com.follow.clash.service.IRemoteInterface
|
||||
import com.follow.clash.service.IResultInterface
|
||||
import com.follow.clash.service.IVoidInterface
|
||||
import com.follow.clash.service.RemoteService
|
||||
import com.follow.clash.service.models.NotificationParams
|
||||
import com.follow.clash.service.models.VpnOptions
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
object Service {
|
||||
private val delegate by lazy {
|
||||
ServiceDelegate<IRemoteInterface>(
|
||||
RemoteService::class.intent, ::handleServiceDisconnected
|
||||
) {
|
||||
IRemoteInterface.Stub.asInterface(it)
|
||||
}
|
||||
}
|
||||
|
||||
var onServiceDisconnected: ((String) -> Unit)? = null
|
||||
|
||||
private fun handleServiceDisconnected(message: String) {
|
||||
onServiceDisconnected?.let {
|
||||
it(message)
|
||||
}
|
||||
}
|
||||
|
||||
fun bind() {
|
||||
delegate.bind()
|
||||
}
|
||||
|
||||
fun unbind() {
|
||||
delegate.unbind()
|
||||
}
|
||||
|
||||
suspend fun invokeAction(data: String, cb: ((result: String) -> Unit)?): Result<Unit> {
|
||||
val res = mutableListOf<ByteArray>()
|
||||
return delegate.useService {
|
||||
it.invokeAction(
|
||||
data, object : ICallbackInterface.Stub() {
|
||||
override fun onResult(
|
||||
result: ByteArray?, isSuccess: Boolean, ack: IAckInterface?
|
||||
) {
|
||||
res.add(result ?: byteArrayOf())
|
||||
ack?.onAck()
|
||||
if (isSuccess) {
|
||||
cb?.let { cb ->
|
||||
cb(res.formatString())
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun quickSetup(
|
||||
initParamsString: String,
|
||||
setupParamsString: String,
|
||||
onStarted: (() -> Unit)?,
|
||||
onResult: ((result: String) -> Unit)?,
|
||||
): Result<Unit> {
|
||||
val res = mutableListOf<ByteArray>()
|
||||
return delegate.useService {
|
||||
it.quickSetup(
|
||||
initParamsString,
|
||||
setupParamsString,
|
||||
object : ICallbackInterface.Stub() {
|
||||
override fun onResult(
|
||||
result: ByteArray?, isSuccess: Boolean, ack: IAckInterface?
|
||||
) {
|
||||
res.add(result ?: byteArrayOf())
|
||||
ack?.onAck()
|
||||
if (isSuccess) {
|
||||
onResult?.let { cb ->
|
||||
cb(res.formatString())
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
object : IVoidInterface.Stub() {
|
||||
override fun invoke() {
|
||||
onStarted?.let { onStarted ->
|
||||
onStarted()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setEventListener(
|
||||
cb: ((result: String?) -> Unit)?
|
||||
): Result<Unit> {
|
||||
val results = HashMap<String, MutableList<ByteArray>>()
|
||||
return delegate.useService {
|
||||
it.setEventListener(
|
||||
when (cb != null) {
|
||||
true -> object : IEventInterface.Stub() {
|
||||
override fun onEvent(
|
||||
id: String, data: ByteArray?, isSuccess: Boolean, ack: IAckInterface?
|
||||
) {
|
||||
if (results[id] == null) {
|
||||
results[id] = mutableListOf()
|
||||
}
|
||||
results[id]?.add(data ?: byteArrayOf())
|
||||
ack?.onAck()
|
||||
if (isSuccess) {
|
||||
cb(results[id]?.formatString())
|
||||
results.remove(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false -> null
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateNotificationParams(
|
||||
params: NotificationParams
|
||||
): Result<Unit> {
|
||||
return delegate.useService {
|
||||
it.updateNotificationParams(params)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setCrashlytics(
|
||||
enable: Boolean
|
||||
): Result<Unit> {
|
||||
return delegate.useService {
|
||||
it.setCrashlytics(enable)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun awaitIResultInterface(
|
||||
block: (IResultInterface) -> Unit
|
||||
): Long = suspendCancellableCoroutine { continuation ->
|
||||
val callback = object : IResultInterface.Stub() {
|
||||
override fun onResult(time: Long) {
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(time)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
block(callback)
|
||||
} catch (e: Exception) {
|
||||
GlobalState.log("awaitIResultInterface $e")
|
||||
if (continuation.isActive) {
|
||||
continuation.resumeWithException(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
suspend fun startService(options: VpnOptions, runTime: Long): Long {
|
||||
return delegate.useService {
|
||||
awaitIResultInterface { callback ->
|
||||
it.startService(options, runTime, callback)
|
||||
}
|
||||
}.getOrNull() ?: 0L
|
||||
}
|
||||
|
||||
suspend fun stopService(): Long {
|
||||
return delegate.useService {
|
||||
awaitIResultInterface { callback ->
|
||||
it.stopService(callback)
|
||||
}
|
||||
}.getOrNull() ?: 0L
|
||||
}
|
||||
|
||||
suspend fun getRunTime(): Long {
|
||||
return delegate.useService {
|
||||
it.runTime
|
||||
}.getOrNull() ?: 0L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
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.BroadcastLease
|
||||
import com.follow.clash.common.GlobalState
|
||||
import com.follow.clash.common.action
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ServiceBroadcastReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
val action = intent?.action ?: return
|
||||
val pendingResult = goAsync()
|
||||
val lease = BroadcastLease { pendingResult.finish() }
|
||||
val timeout = Runnable {
|
||||
lease.release { GlobalState.log("Broadcast handling timed out: $action") }
|
||||
}
|
||||
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)
|
||||
lease.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleAction(action: String) {
|
||||
when (action) {
|
||||
BroadcastAction.VPN_START_REQUESTED.action -> {
|
||||
GlobalState.log("System requested VPN service start")
|
||||
ServiceState.handleStartAction()
|
||||
}
|
||||
|
||||
BroadcastAction.VPN_REVOKED.action -> {
|
||||
GlobalState.log("VPN permission revoked")
|
||||
ServiceState.handleVpnRevokeAction()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val BROADCAST_TIMEOUT_MILLIS = 9_000L
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
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: (ByteArray) -> Unit): Result<Unit> = runCatching {
|
||||
Core.invokeMethod(data) { result ->
|
||||
callback(result ?: ByteArray(0))
|
||||
}
|
||||
}
|
||||
|
||||
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): Long = lock.withLock {
|
||||
ServiceConfig.updateVpnOptions(options)
|
||||
val nextIntent = if (options.enable) {
|
||||
VpnService::class.intent
|
||||
} else {
|
||||
ProxyService::class.intent
|
||||
}
|
||||
|
||||
if (binding?.component != nextIntent.component) {
|
||||
if (binding != null) {
|
||||
tearDownServices()
|
||||
}
|
||||
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()
|
||||
runTimeMillis = 0L
|
||||
return@withLock runTimeMillis
|
||||
}
|
||||
}
|
||||
|
||||
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()}")
|
||||
currentBinding.stopIfConnected()
|
||||
.onFailure { error ->
|
||||
GlobalState.log("Unable to clean up failed background service start: $error")
|
||||
}
|
||||
clearBinding()
|
||||
runTimeMillis = 0L
|
||||
return@withLock runTimeMillis
|
||||
}
|
||||
|
||||
if (runTimeMillis == 0L) {
|
||||
runTimeMillis = System.currentTimeMillis()
|
||||
}
|
||||
runTimeMillis
|
||||
}
|
||||
|
||||
suspend fun stop() = lock.withLock {
|
||||
tearDownServices()
|
||||
runTimeMillis = 0L
|
||||
}
|
||||
|
||||
private suspend fun tearDownServices() {
|
||||
binding?.useService { service -> service.stop() }
|
||||
?.onFailure { error ->
|
||||
GlobalState.log("Unable to stop background service: $error")
|
||||
}
|
||||
clearBinding()
|
||||
stopServices()
|
||||
}
|
||||
|
||||
// A service the system started itself — always-on VPN, or the sticky restart
|
||||
// after the process was killed — outlives every binding this process holds, so
|
||||
// unbinding alone leaves the tunnel up while the app reports it stopped.
|
||||
private fun stopServices() {
|
||||
listOf(VpnService::class.intent, ProxyService::class.intent).forEach { intent ->
|
||||
runCatching { GlobalState.application.stopService(intent) }
|
||||
.onFailure { error ->
|
||||
GlobalState.log("Unable to stop ${intent.component?.className}: $error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun isVpnServiceActive(): Boolean = lock.withLock {
|
||||
runTimeMillis != 0L && binding?.component == VpnService::class.intent.component
|
||||
}
|
||||
|
||||
fun getRunTimeMillis(): Long = runTimeMillis
|
||||
|
||||
private fun handleServiceDisconnected(
|
||||
disconnectedBinding: ManagedServiceBinding,
|
||||
message: String,
|
||||
) {
|
||||
val token = ServiceState.captureRequestToken()
|
||||
GlobalState.launch {
|
||||
val wasCurrent = lock.withLock {
|
||||
if (binding !== disconnectedBinding) {
|
||||
return@withLock false
|
||||
}
|
||||
GlobalState.log("Background service disconnected: $message")
|
||||
clearBinding()
|
||||
runTimeMillis = 0L
|
||||
true
|
||||
}
|
||||
if (wasCurrent) {
|
||||
ServiceState.handleServiceLost(token)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
connectionTimeoutMillis: Long = 5_000,
|
||||
block: suspend (ManagedService) -> R,
|
||||
): Result<R> = runCatching {
|
||||
val service = withTimeout(connectionTimeoutMillis) {
|
||||
serviceState.filterNotNull().first().getOrThrow()
|
||||
}
|
||||
withContext(Dispatchers.Default) {
|
||||
block(service)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun stopIfConnected(): Result<Unit> = runCatching {
|
||||
val service = serviceState.value?.getOrNull() ?: return@runCatching
|
||||
withContext(Dispatchers.Default) {
|
||||
service.stop()
|
||||
}
|
||||
}
|
||||
|
||||
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,37 @@
|
||||
package com.follow.clash
|
||||
|
||||
import com.follow.clash.models.SharedState
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import kotlinx.coroutines.Deferred
|
||||
|
||||
object ServiceState {
|
||||
private val machine = ServiceStateMachine(AndroidServiceStateHost)
|
||||
|
||||
val runState = machine.runState
|
||||
|
||||
fun attachFlutterEngine(engine: FlutterEngine) =
|
||||
AndroidServiceStateHost.attachFlutterEngine(engine)
|
||||
|
||||
fun detachFlutterEngine(engine: FlutterEngine) =
|
||||
AndroidServiceStateHost.detachFlutterEngine(engine)
|
||||
|
||||
suspend fun handleToggleAction() = machine.handleToggleAction()
|
||||
|
||||
suspend fun handleStartAction() = machine.handleStartAction()
|
||||
|
||||
suspend fun handleStopAction() = machine.handleStopAction()
|
||||
|
||||
suspend fun handleVpnRevokeAction() = machine.handleVpnRevokeAction()
|
||||
|
||||
suspend fun refresh(): Long = machine.refresh()
|
||||
|
||||
fun requestStart(): Deferred<Boolean> = machine.requestStart()
|
||||
|
||||
fun requestStop(): Deferred<Boolean> = machine.requestStop()
|
||||
|
||||
fun syncSharedState(state: SharedState) = machine.syncSharedState(state)
|
||||
|
||||
internal fun captureRequestToken(): RunRequest = machine.captureRequestToken()
|
||||
|
||||
internal suspend fun handleServiceLost(token: RunRequest) = machine.handleServiceLost(token)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package com.follow.clash
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.VpnService
|
||||
import android.os.Build
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.follow.clash.common.GlobalState
|
||||
import com.follow.clash.models.SharedState
|
||||
import com.follow.clash.plugins.AppPlugin
|
||||
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 io.flutter.embedding.engine.FlutterEngine
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
/**
|
||||
* The machine owns the arbitration and the transitions; this is only the part that cannot run on a
|
||||
* plain JVM, so unit tests can drive the state machine with an in-memory implementation.
|
||||
*/
|
||||
internal interface ServiceStateHost {
|
||||
val scope: CoroutineScope
|
||||
val runTimeMillis: Long
|
||||
val homeDirPath: String
|
||||
val sdkInt: Int
|
||||
|
||||
fun log(message: String)
|
||||
|
||||
fun showToast(message: String)
|
||||
|
||||
fun setCrashlytics(enabled: Boolean)
|
||||
|
||||
fun updateNotificationParams(params: NotificationParams)
|
||||
|
||||
fun loadSharedState(): SharedState
|
||||
|
||||
fun isVpnPermissionGranted(): Boolean
|
||||
|
||||
fun isLocalNetworkPermissionGranted(): Boolean
|
||||
|
||||
fun tile(): TileGateway?
|
||||
|
||||
fun app(): AppGateway?
|
||||
|
||||
suspend fun quickSetup(initParams: String, setupParams: String): Result<String>
|
||||
|
||||
suspend fun startService(options: VpnOptions): Long
|
||||
|
||||
suspend fun stopService()
|
||||
|
||||
suspend fun isVpnServiceActive(): Boolean
|
||||
}
|
||||
|
||||
internal interface TileGateway {
|
||||
fun handleStart()
|
||||
|
||||
fun handleStop()
|
||||
}
|
||||
|
||||
internal interface AppGateway {
|
||||
fun requestNotificationPermission(callback: (Boolean) -> Unit)
|
||||
|
||||
fun requestLocalNetworkPermission(callback: (Boolean) -> Unit)
|
||||
|
||||
fun prepareVpn(enable: Boolean, callback: (Boolean) -> Unit)
|
||||
|
||||
fun cancelVpnPreparation(callback: (Boolean) -> Unit)
|
||||
}
|
||||
|
||||
internal object AndroidServiceStateHost : ServiceStateHost {
|
||||
@Volatile
|
||||
private var flutterEngine: FlutterEngine? = null
|
||||
|
||||
override val scope: CoroutineScope
|
||||
get() = GlobalState
|
||||
|
||||
override val runTimeMillis: Long
|
||||
get() = ServiceController.getRunTimeMillis()
|
||||
|
||||
override val homeDirPath: String
|
||||
get() = GlobalState.application.filesDir.path
|
||||
|
||||
override val sdkInt: Int
|
||||
get() = android.os.Build.VERSION.SDK_INT
|
||||
|
||||
fun attachFlutterEngine(engine: FlutterEngine) {
|
||||
flutterEngine = engine
|
||||
}
|
||||
|
||||
fun detachFlutterEngine(engine: FlutterEngine) {
|
||||
if (flutterEngine === engine) {
|
||||
flutterEngine = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun log(message: String) = GlobalState.log(message)
|
||||
|
||||
override fun showToast(message: String) = GlobalState.application.showToast(message)
|
||||
|
||||
override fun setCrashlytics(enabled: Boolean) = GlobalState.setCrashlytics(enabled)
|
||||
|
||||
override fun updateNotificationParams(params: NotificationParams) =
|
||||
ServiceConfig.updateNotificationParams(params)
|
||||
|
||||
override fun loadSharedState(): SharedState = GlobalState.application.sharedState
|
||||
|
||||
override fun isVpnPermissionGranted(): Boolean =
|
||||
VpnService.prepare(GlobalState.application) == null
|
||||
|
||||
override fun isLocalNetworkPermissionGranted(): Boolean =
|
||||
sdkInt < Build.VERSION_CODES.CINNAMON_BUN ||
|
||||
ContextCompat.checkSelfPermission(
|
||||
GlobalState.application,
|
||||
Manifest.permission.ACCESS_LOCAL_NETWORK,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
override fun tile(): TileGateway? = flutterEngine?.plugin<TilePlugin>()?.let { plugin ->
|
||||
object : TileGateway {
|
||||
override fun handleStart() = plugin.handleStart()
|
||||
|
||||
override fun handleStop() = plugin.handleStop()
|
||||
}
|
||||
}
|
||||
|
||||
override fun app(): AppGateway? = flutterEngine?.plugin<AppPlugin>()?.let { plugin ->
|
||||
object : AppGateway {
|
||||
override fun requestNotificationPermission(callback: (Boolean) -> Unit) =
|
||||
plugin.requestNotificationPermission(callback)
|
||||
|
||||
override fun requestLocalNetworkPermission(callback: (Boolean) -> Unit) =
|
||||
plugin.requestLocalNetworkPermission(callback)
|
||||
|
||||
override fun prepareVpn(enable: Boolean, callback: (Boolean) -> Unit) =
|
||||
plugin.prepareVpn(enable, callback)
|
||||
|
||||
override fun cancelVpnPreparation(callback: (Boolean) -> Unit) =
|
||||
plugin.cancelVpnPreparation(callback)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun quickSetup(initParams: String, setupParams: String): Result<String> =
|
||||
ServiceController.quickSetup(initParams, setupParams)
|
||||
|
||||
override suspend fun startService(options: VpnOptions): Long =
|
||||
ServiceController.start(options)
|
||||
|
||||
override suspend fun stopService() = ServiceController.stop()
|
||||
|
||||
override suspend fun isVpnServiceActive(): Boolean = ServiceController.isVpnServiceActive()
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
package com.follow.clash
|
||||
|
||||
import com.follow.clash.common.RunIntentArbiter
|
||||
import com.follow.clash.models.SharedState
|
||||
import com.follow.clash.service.models.NotificationParams
|
||||
import com.follow.clash.service.models.VpnOptions
|
||||
import com.google.gson.Gson
|
||||
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,
|
||||
}
|
||||
|
||||
internal typealias RunRequest = RunIntentArbiter.Token
|
||||
|
||||
internal const val MISSING_CONFIG_MESSAGE = "No configuration found."
|
||||
internal const val INVALID_CONFIG_MESSAGE = "Invalid configuration."
|
||||
internal const val VPN_PERMISSION_MESSAGE = "VPN permission required."
|
||||
internal const val START_FAILED_MESSAGE = "Failed to start service."
|
||||
|
||||
/**
|
||||
* Callers request a transition; the newest request always wins. Every step that outlives its own
|
||||
* suspension point re-checks [isCurrent] before it publishes anything, so a start that was overtaken
|
||||
* by a stop cannot report itself as started.
|
||||
*/
|
||||
internal class ServiceStateMachine(private val host: ServiceStateHost) {
|
||||
private val transitionLock = Mutex()
|
||||
private val startPreparationLock = Mutex()
|
||||
private val mutableRunState = MutableStateFlow(RunState.STOPPED)
|
||||
private val arbiter = RunIntentArbiter()
|
||||
|
||||
@Volatile
|
||||
private var sharedState = SharedState()
|
||||
|
||||
@Volatile
|
||||
private var pendingVpnPreparation: (() -> Unit)? = null
|
||||
|
||||
val runState = mutableRunState.asStateFlow()
|
||||
|
||||
private val runTimeMillis: Long
|
||||
get() = host.runTimeMillis
|
||||
|
||||
suspend fun handleToggleAction() {
|
||||
if (isRunningRequested()) {
|
||||
handleStopAction()
|
||||
} else {
|
||||
handleStartAction()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun refresh(): Long = transitionLock.withLock {
|
||||
val current = runTimeMillis
|
||||
mutableRunState.value = if (current == 0L) RunState.STOPPED else RunState.STARTED
|
||||
current
|
||||
}
|
||||
|
||||
fun captureRequestToken(): RunRequest = arbiter.current()
|
||||
|
||||
/**
|
||||
* Settles the state after the bound service was lost. [token] is the request that was current
|
||||
* when the loss was observed, so a start that raced ahead of this callback keeps its intent.
|
||||
*/
|
||||
suspend fun handleServiceLost(token: RunRequest) = transitionLock.withLock {
|
||||
if (runTimeMillis != 0L) {
|
||||
return@withLock
|
||||
}
|
||||
if (!arbiter.resetToStopped(token)) {
|
||||
return@withLock
|
||||
}
|
||||
mutableRunState.value = RunState.STOPPED
|
||||
}
|
||||
|
||||
suspend fun handleStartAction() {
|
||||
if (isRunningRequested()) {
|
||||
return
|
||||
}
|
||||
val tile = host.tile()
|
||||
if (tile != null) {
|
||||
tile.handleStart()
|
||||
return
|
||||
}
|
||||
loadPreferencesAndStart()
|
||||
}
|
||||
|
||||
suspend fun handleStopAction() {
|
||||
if (!isRunningRequested()) {
|
||||
return
|
||||
}
|
||||
val tile = host.tile()
|
||||
if (tile != null) {
|
||||
tile.handleStop()
|
||||
return
|
||||
}
|
||||
host.showToast(sharedState.stopTip)
|
||||
requestStop().await()
|
||||
}
|
||||
|
||||
suspend fun handleVpnRevokeAction() {
|
||||
if (!host.isVpnServiceActive()) {
|
||||
return
|
||||
}
|
||||
handleStopAction()
|
||||
}
|
||||
|
||||
fun requestStart(): Deferred<Boolean> {
|
||||
val request = createRequest(running = true)
|
||||
val result = CompletableDeferred<Boolean>()
|
||||
val launchRequest: (Boolean) -> Unit = { shouldStart ->
|
||||
if (!shouldStart) {
|
||||
fail(request)
|
||||
result.complete(false)
|
||||
} else {
|
||||
host.scope.launch {
|
||||
result.complete(
|
||||
runCatching { start(request) }
|
||||
.onFailure { error ->
|
||||
host.log("Unable to process service start request: $error")
|
||||
fail(request)
|
||||
reconcileStopped()
|
||||
}
|
||||
.getOrDefault(false),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val app = host.app()
|
||||
if (app != null) {
|
||||
app.requestNotificationPermission { shouldStart ->
|
||||
if (shouldStart) {
|
||||
app.requestLocalNetworkPermission { launchRequest(true) }
|
||||
} else {
|
||||
launchRequest(false)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
launchRequest(true)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fun requestStop(): Deferred<Boolean> {
|
||||
val request = createRequest(running = false)
|
||||
val result = CompletableDeferred<Boolean>()
|
||||
host.scope.launch {
|
||||
result.complete(
|
||||
runCatching { stop(request) }
|
||||
.onFailure { error ->
|
||||
host.log("Unable to process service stop request: $error")
|
||||
}
|
||||
.getOrDefault(false),
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fun syncSharedState(state: SharedState) {
|
||||
sharedState = state
|
||||
applySharedState()
|
||||
}
|
||||
|
||||
private suspend fun loadPreferencesAndStart() {
|
||||
sharedState = host.loadSharedState()
|
||||
if (sharedState.setupParams == null || sharedState.vpnOptions == null) {
|
||||
host.showToast(MISSING_CONFIG_MESSAGE)
|
||||
return
|
||||
}
|
||||
if (setupCore()) {
|
||||
if (!requestStart().await()) {
|
||||
host.showToast(START_FAILED_MESSAGE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun applySharedState() {
|
||||
host.setCrashlytics(sharedState.crashlytics)
|
||||
host.updateNotificationParams(notificationParams(sharedState))
|
||||
}
|
||||
|
||||
private suspend fun setupCore(): Boolean {
|
||||
applySharedState()
|
||||
host.showToast(sharedState.startTip)
|
||||
return host.quickSetup(
|
||||
initParams(host.homeDirPath, host.sdkInt),
|
||||
Gson().toJson(sharedState.setupParams),
|
||||
).fold(
|
||||
onSuccess = { message ->
|
||||
if (message.isEmpty()) {
|
||||
true
|
||||
} else {
|
||||
host.log("Unable to set up core: $message")
|
||||
showConfigError(message)
|
||||
false
|
||||
}
|
||||
},
|
||||
onFailure = { error ->
|
||||
host.log("Unable to set up core: $error")
|
||||
showConfigError(error.message)
|
||||
false
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun showConfigError(message: String?) {
|
||||
host.showToast(message?.takeIf { it.isNotBlank() } ?: INVALID_CONFIG_MESSAGE)
|
||||
}
|
||||
|
||||
private suspend fun start(request: RunRequest): Boolean = startPreparationLock.withLock {
|
||||
val started = runStart(request)
|
||||
if (!started) {
|
||||
reconcileStopped()
|
||||
}
|
||||
started
|
||||
}
|
||||
|
||||
private suspend fun runStart(request: RunRequest): Boolean {
|
||||
if (!isCurrent(request)) {
|
||||
return false
|
||||
}
|
||||
val options = sharedState.vpnOptions
|
||||
if (options == null) {
|
||||
fail(request)
|
||||
return false
|
||||
}
|
||||
if (!prepareVpn(options)) {
|
||||
if (host.app() == null && isCurrent(request)) {
|
||||
host.showToast(VPN_PERMISSION_MESSAGE)
|
||||
}
|
||||
fail(request)
|
||||
return false
|
||||
}
|
||||
if (!isCurrent(request)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return transitionLock.withLock transition@{
|
||||
if (!isCurrent(request)) {
|
||||
return@transition false
|
||||
}
|
||||
if (runTimeMillis != 0L && host.isVpnServiceActive() == options.enable) {
|
||||
mutableRunState.value = RunState.STARTED
|
||||
return@transition true
|
||||
}
|
||||
mutableRunState.value = RunState.STARTING
|
||||
val startedAtMillis = host.startService(withLocalNetworkFallback(options))
|
||||
if (startedAtMillis == 0L) {
|
||||
mutableRunState.value = RunState.STOPPED
|
||||
fail(request)
|
||||
return@transition false
|
||||
}
|
||||
if (!isCurrent(request)) {
|
||||
return@transition false
|
||||
}
|
||||
mutableRunState.value = RunState.STARTED
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
// system/mixed hand TCP to the kernel via the tun subnet, which Android 17 gates as local network.
|
||||
private fun withLocalNetworkFallback(options: VpnOptions): VpnOptions {
|
||||
if (!options.enable || options.stack !in KERNEL_TCP_STACKS ||
|
||||
host.isLocalNetworkPermissionGranted()
|
||||
) {
|
||||
return options
|
||||
}
|
||||
host.showToast(sharedState.localNetworkTip)
|
||||
return options.copy(stack = FALLBACK_STACK)
|
||||
}
|
||||
|
||||
private suspend fun reconcileStopped() = transitionLock.withLock {
|
||||
if (isRunningRequested() || runTimeMillis == 0L) {
|
||||
return@withLock
|
||||
}
|
||||
mutableRunState.value = RunState.STOPPING
|
||||
host.stopService()
|
||||
mutableRunState.value = RunState.STOPPED
|
||||
}
|
||||
|
||||
private suspend fun stop(request: RunRequest): Boolean = transitionLock.withLock {
|
||||
if (!isCurrent(request)) {
|
||||
return@withLock false
|
||||
}
|
||||
abandonVpnPreparation()
|
||||
if (runState.value == RunState.STOPPED && runTimeMillis == 0L) {
|
||||
return@withLock true
|
||||
}
|
||||
mutableRunState.value = RunState.STOPPING
|
||||
host.stopService()
|
||||
mutableRunState.value = RunState.STOPPED
|
||||
isCurrent(request)
|
||||
}
|
||||
|
||||
private suspend fun prepareVpn(options: VpnOptions): Boolean {
|
||||
val app = host.app()
|
||||
?: return !options.enable || host.isVpnPermissionGranted()
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
val callback: (Boolean) -> Unit = { granted ->
|
||||
pendingVpnPreparation = null
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(granted)
|
||||
}
|
||||
}
|
||||
pendingVpnPreparation = {
|
||||
app.cancelVpnPreparation(callback)
|
||||
callback(false)
|
||||
}
|
||||
continuation.invokeOnCancellation {
|
||||
pendingVpnPreparation = null
|
||||
app.cancelVpnPreparation(callback)
|
||||
}
|
||||
app.prepareVpn(options.enable, callback)
|
||||
}
|
||||
}
|
||||
|
||||
private fun abandonVpnPreparation() {
|
||||
val abandon = pendingVpnPreparation ?: return
|
||||
pendingVpnPreparation = null
|
||||
abandon()
|
||||
}
|
||||
|
||||
private fun createRequest(running: Boolean): RunRequest = arbiter.request(running)
|
||||
|
||||
private fun isRunningRequested(): Boolean = arbiter.isRunningRequested
|
||||
|
||||
private fun isCurrent(request: RunRequest): Boolean = arbiter.isCurrent(request)
|
||||
|
||||
private fun fail(request: RunRequest) {
|
||||
arbiter.resetToStopped(request)
|
||||
}
|
||||
|
||||
internal companion object {
|
||||
val KERNEL_TCP_STACKS = setOf("system", "mixed")
|
||||
const val FALLBACK_STACK = "gvisor"
|
||||
|
||||
/**
|
||||
* The Core init payload. The key spelling is a cross-language contract with the Go wrapper,
|
||||
* not an implementation detail.
|
||||
*/
|
||||
fun initParams(homeDirPath: String, sdkInt: Int): String = Gson().toJson(
|
||||
mapOf(
|
||||
"home-dir" to homeDirPath,
|
||||
"version" to sdkInt,
|
||||
),
|
||||
)
|
||||
|
||||
fun notificationParams(state: SharedState): NotificationParams = NotificationParams(
|
||||
title = state.currentProfileName,
|
||||
stopText = state.stopText,
|
||||
onlyStatisticsProxy = state.onlyStatisticsProxy,
|
||||
showStopAction = state.showStopAction,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
package com.follow.clash
|
||||
|
||||
import android.net.VpnService
|
||||
import com.follow.clash.common.GlobalState
|
||||
import com.follow.clash.models.SharedState
|
||||
import com.follow.clash.plugins.AppPlugin
|
||||
import com.follow.clash.plugins.TilePlugin
|
||||
import com.follow.clash.service.models.NotificationParams
|
||||
import com.google.gson.Gson
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
enum class RunState {
|
||||
START, PENDING, STOP
|
||||
}
|
||||
|
||||
|
||||
object State {
|
||||
|
||||
val runLock = Mutex()
|
||||
|
||||
var runTime: Long = 0
|
||||
|
||||
var sharedState: SharedState = SharedState()
|
||||
|
||||
val runStateFlow: MutableStateFlow<RunState> = MutableStateFlow(RunState.STOP)
|
||||
|
||||
var flutterEngine: FlutterEngine? = null
|
||||
|
||||
val appPlugin: AppPlugin?
|
||||
get() = flutterEngine?.plugin<AppPlugin>()
|
||||
|
||||
val tilePlugin: TilePlugin?
|
||||
get() = flutterEngine?.plugin<TilePlugin>()
|
||||
|
||||
suspend fun handleToggleAction() {
|
||||
var action: (suspend () -> Unit)?
|
||||
runLock.withLock {
|
||||
action = when (runStateFlow.value) {
|
||||
RunState.PENDING -> null
|
||||
RunState.START -> ::handleStopServiceAction
|
||||
RunState.STOP -> ::handleStartServiceAction
|
||||
}
|
||||
}
|
||||
action?.invoke()
|
||||
}
|
||||
|
||||
suspend fun handleSyncState() {
|
||||
runLock.withLock {
|
||||
try {
|
||||
Service.bind()
|
||||
runTime = Service.getRunTime()
|
||||
val runState = when (runTime == 0L) {
|
||||
true -> RunState.STOP
|
||||
false -> RunState.START
|
||||
}
|
||||
runStateFlow.tryEmit(runState)
|
||||
} catch (_: Exception) {
|
||||
runStateFlow.tryEmit(RunState.STOP)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun handleStartServiceAction() {
|
||||
runLock.withLock {
|
||||
if (runStateFlow.value != RunState.STOP) {
|
||||
return
|
||||
}
|
||||
tilePlugin?.handleStart()
|
||||
if (flutterEngine != null) {
|
||||
return
|
||||
}
|
||||
startServiceWithPref()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
suspend fun handleStopServiceAction() {
|
||||
runLock.withLock {
|
||||
if (runStateFlow.value != RunState.START) {
|
||||
return
|
||||
}
|
||||
tilePlugin?.handleStop()
|
||||
if (flutterEngine != null) {
|
||||
return
|
||||
}
|
||||
GlobalState.application.showToast(sharedState.stopTip)
|
||||
handleStopService()
|
||||
}
|
||||
}
|
||||
|
||||
fun handleStartService() {
|
||||
val appPlugin = flutterEngine?.plugin<AppPlugin>()
|
||||
if (appPlugin != null) {
|
||||
appPlugin.requestNotificationsPermission {
|
||||
startService()
|
||||
}
|
||||
return
|
||||
}
|
||||
startService()
|
||||
}
|
||||
|
||||
private fun startServiceWithPref() {
|
||||
GlobalState.launch {
|
||||
runLock.withLock {
|
||||
if (runStateFlow.value != RunState.STOP) {
|
||||
return@launch
|
||||
}
|
||||
sharedState = GlobalState.application.sharedState
|
||||
setupAndStart()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun syncState() {
|
||||
GlobalState.setCrashlytics(sharedState.crashlytics)
|
||||
Service.updateNotificationParams(
|
||||
NotificationParams(
|
||||
title = sharedState.currentProfileName,
|
||||
stopText = sharedState.stopText,
|
||||
onlyStatisticsProxy = sharedState.onlyStatisticsProxy
|
||||
)
|
||||
)
|
||||
Service.setCrashlytics(sharedState.crashlytics)
|
||||
}
|
||||
|
||||
private suspend fun setupAndStart() {
|
||||
Service.bind()
|
||||
syncState()
|
||||
GlobalState.application.showToast(sharedState.startTip)
|
||||
val initParams = mutableMapOf<String, Any>()
|
||||
initParams["home-dir"] = GlobalState.application.filesDir.path
|
||||
initParams["version"] = android.os.Build.VERSION.SDK_INT
|
||||
val initParamsString = Gson().toJson(initParams)
|
||||
val setupParamsString = Gson().toJson(sharedState.setupParams)
|
||||
Service.quickSetup(
|
||||
initParamsString,
|
||||
setupParamsString,
|
||||
onStarted = {
|
||||
startService()
|
||||
},
|
||||
onResult = {
|
||||
if (it.isNotEmpty()) {
|
||||
GlobalState.application.showToast(it)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun startService() {
|
||||
GlobalState.launch {
|
||||
runLock.withLock {
|
||||
if (runStateFlow.value != RunState.STOP) {
|
||||
return@launch
|
||||
}
|
||||
try {
|
||||
runStateFlow.tryEmit(RunState.PENDING)
|
||||
val options = sharedState.vpnOptions ?: return@launch
|
||||
appPlugin?.let {
|
||||
it.prepare(options.enable) {
|
||||
runTime = Service.startService(options, runTime)
|
||||
runStateFlow.tryEmit(RunState.START)
|
||||
}
|
||||
} ?: run {
|
||||
val intent = VpnService.prepare(GlobalState.application)
|
||||
if (intent != null) {
|
||||
return@launch
|
||||
}
|
||||
runTime = Service.startService(options, runTime)
|
||||
runStateFlow.tryEmit(RunState.START)
|
||||
}
|
||||
} finally {
|
||||
if (runStateFlow.value == RunState.PENDING) {
|
||||
runStateFlow.tryEmit(RunState.STOP)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun handleStopService() {
|
||||
GlobalState.launch {
|
||||
runLock.withLock {
|
||||
if (runStateFlow.value != RunState.START) {
|
||||
return@launch
|
||||
}
|
||||
try {
|
||||
runStateFlow.tryEmit(RunState.PENDING)
|
||||
runTime = Service.stopService()
|
||||
runStateFlow.tryEmit(RunState.STOP)
|
||||
} finally {
|
||||
if (runStateFlow.value == RunState.PENDING) {
|
||||
runStateFlow.tryEmit(RunState.START)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
package com.follow.clash
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Bundle
|
||||
import com.follow.clash.common.QuickAction
|
||||
import com.follow.clash.common.action
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class TempActivity : Activity(),
|
||||
CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
when (intent.action) {
|
||||
QuickAction.START.action -> {
|
||||
launch {
|
||||
State.handleStartServiceAction()
|
||||
}
|
||||
}
|
||||
|
||||
QuickAction.STOP.action -> {
|
||||
launch {
|
||||
State.handleStopServiceAction()
|
||||
}
|
||||
}
|
||||
|
||||
QuickAction.TOGGLE.action -> {
|
||||
launch {
|
||||
State.handleToggleAction()
|
||||
}
|
||||
}
|
||||
}
|
||||
finish()
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package com.follow.clash
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Build
|
||||
import android.service.quicksettings.Tile
|
||||
import android.service.quicksettings.TileService
|
||||
import com.follow.clash.common.QuickAction
|
||||
import com.follow.clash.common.quickIntent
|
||||
import com.follow.clash.common.toPendingIntent
|
||||
@@ -13,49 +12,53 @@ import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class TileService : TileService() {
|
||||
class TileService : android.service.quicksettings.TileService() {
|
||||
private var scope: CoroutineScope? = null
|
||||
private fun updateTile(runState: RunState) {
|
||||
if (qsTile != null) {
|
||||
qsTile.state = when (runState) {
|
||||
RunState.START -> Tile.STATE_ACTIVE
|
||||
RunState.PENDING -> Tile.STATE_UNAVAILABLE
|
||||
RunState.STOP -> Tile.STATE_INACTIVE
|
||||
}
|
||||
qsTile.updateTile()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartListening() {
|
||||
super.onStartListening()
|
||||
scope?.cancel()
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
scope?.launch {
|
||||
State.handleSyncState()
|
||||
State.runStateFlow.collect {
|
||||
updateTile(it)
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate).also { scope ->
|
||||
scope.launch {
|
||||
ServiceState.refresh()
|
||||
ServiceState.runState.collect(::updateTile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("StartActivityAndCollapseDeprecated")
|
||||
private fun handleToggle() {
|
||||
val intent = QuickAction.TOGGLE.quickIntent
|
||||
val pendingIntent = intent.toPendingIntent
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
startActivityAndCollapse(pendingIntent)
|
||||
} else {
|
||||
@Suppress("DEPRECATION") startActivityAndCollapse(intent)
|
||||
}
|
||||
}
|
||||
|
||||
// Always route through QuickActionActivity: startActivityAndCollapse is
|
||||
// what closes the shade, so toggling the service in place would leave
|
||||
// the panel open after a tap.
|
||||
override fun onClick() {
|
||||
super.onClick()
|
||||
handleToggle()
|
||||
openQuickAction()
|
||||
}
|
||||
|
||||
override fun onStopListening() {
|
||||
scope?.cancel()
|
||||
scope = null
|
||||
super.onStopListening()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateTile(runState: RunState) {
|
||||
qsTile?.apply {
|
||||
state = when (runState) {
|
||||
RunState.STARTED -> Tile.STATE_ACTIVE
|
||||
RunState.STARTING, RunState.STOPPING -> Tile.STATE_UNAVAILABLE
|
||||
RunState.STOPPED -> Tile.STATE_INACTIVE
|
||||
}
|
||||
updateTile()
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("StartActivityAndCollapseDeprecated")
|
||||
private fun openQuickAction() {
|
||||
val intent = QuickAction.TOGGLE.quickIntent
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
startActivityAndCollapse(intent.toPendingIntent)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
startActivityAndCollapse(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
package com.follow.clash.models
|
||||
|
||||
data class Package(
|
||||
data class InstalledPackage(
|
||||
val packageName: String,
|
||||
val label: String,
|
||||
val system: Boolean,
|
||||
@@ -6,10 +6,13 @@ import com.google.gson.annotations.SerializedName
|
||||
data class SharedState(
|
||||
val startTip: String = "Starting VPN...",
|
||||
val stopTip: String = "Stopping VPN...",
|
||||
val localNetworkTip: String =
|
||||
"Local network permission denied: using the gvisor stack, LAN is unreachable.",
|
||||
val crashlytics: Boolean = true,
|
||||
val currentProfileName: String = "FlClash",
|
||||
val stopText: String = "Stop",
|
||||
val onlyStatisticsProxy: Boolean = false,
|
||||
val showStopAction: Boolean = true,
|
||||
val vpnOptions: VpnOptions? = null,
|
||||
val setupParams: SetupParams? = null,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.follow.clash.packages
|
||||
|
||||
/**
|
||||
* The prefixes in [CHINA_PACKAGE_REGEX] are matched *without* a trailing dot
|
||||
* boundary, and that is deliberate: `com.qihoo` has to reach `com.qihoo360.*`
|
||||
* and `com.ali` has to reach `com.aliyun.*` and `com.alimama.*`. Requiring a
|
||||
* separator would turn those into misses.
|
||||
*
|
||||
* The price is that unrelated names starting with the same letters match too.
|
||||
* [SKIPPED_PREFIXES] is where those come back out, and it *does* apply a dot
|
||||
* boundary, because there the entries are whole package roots.
|
||||
*/
|
||||
internal object ChinaPackageMatcher {
|
||||
|
||||
fun isSkipped(packageName: String): Boolean = SKIPPED_PREFIXES.any {
|
||||
packageName == it || packageName.startsWith("$it.")
|
||||
}
|
||||
|
||||
fun matchesKnownPrefix(name: String): Boolean = name.matches(CHINA_PACKAGE_REGEX)
|
||||
|
||||
fun classNameOf(descriptor: String): String = descriptor
|
||||
.removeSurrounding("L", ";")
|
||||
.replace('/', '.')
|
||||
.replace('$', '.')
|
||||
|
||||
private val SKIPPED_PREFIXES = listOf(
|
||||
"com.google",
|
||||
"com.android.chrome",
|
||||
"com.android.vending",
|
||||
"com.microsoft",
|
||||
"com.apple",
|
||||
"com.zhiliaoapp.musically",
|
||||
// Caught by the loose "com.mx" prefix, which targets Maxthon's
|
||||
// com.mx.browser. MX Player is unrelated.
|
||||
"com.mxtech",
|
||||
// Caught by the loose "com.stub" prefix, which targets the
|
||||
// com.stub.StubApp packer. StubHub is unrelated.
|
||||
"com.stubhub",
|
||||
)
|
||||
|
||||
private 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()
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
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,
|
||||
) {
|
||||
private val cacheLock = Any()
|
||||
|
||||
@Volatile
|
||||
private var cachedPackages: List<InstalledPackage>? = null
|
||||
|
||||
val installedPackages: List<InstalledPackage>
|
||||
get() = cachedPackages ?: synchronized(cacheLock) {
|
||||
cachedPackages ?: loadPackages().also { cachedPackages = it }
|
||||
}
|
||||
|
||||
val isInstalledAppsPermissionSupported: Boolean by lazy {
|
||||
runCatching { packageManager.getPermissionInfo(GET_INSTALLED_APPS, 0) }.isSuccess
|
||||
}
|
||||
|
||||
fun hasInstalledAppsPermission(): Boolean = !isInstalledAppsPermissionSupported ||
|
||||
packageManager.checkPermission(GET_INSTALLED_APPS, appPackageName) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
|
||||
fun invalidate() {
|
||||
synchronized(cacheLock) { cachedPackages = null }
|
||||
}
|
||||
|
||||
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 (ChinaPackageMatcher.isSkipped(packageName)) {
|
||||
return false
|
||||
}
|
||||
if (ChinaPackageMatcher.matchesKnownPrefix(packageName)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return runCatching {
|
||||
val packageInfo = getPackageInfo(packageName)
|
||||
packageInfo.componentNames().any(ChinaPackageMatcher::matchesKnownPrefix) ||
|
||||
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 ->
|
||||
ChinaPackageMatcher.matchesKnownPrefix(
|
||||
ChinaPackageMatcher.classNameOf(clazz.type),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val GET_INSTALLED_APPS = "com.android.permission.GET_INSTALLED_APPS"
|
||||
|
||||
private const val ANDROID_PACKAGE_NAME = "android"
|
||||
private const val MAX_DEX_SIZE_BYTES = 15_000_000L
|
||||
|
||||
private val PACKAGE_INFO_FLAGS = PackageManager.GET_ACTIVITIES or
|
||||
PackageManager.GET_SERVICES or
|
||||
PackageManager.GET_RECEIVERS or
|
||||
PackageManager.GET_PROVIDERS
|
||||
}
|
||||
}
|
||||
@@ -1,126 +1,134 @@
|
||||
package com.follow.clash.plugins
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.app.ActivityManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.ComponentInfo
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.VpnService
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
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.pm.ShortcutInfoCompat
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import com.android.tools.smali.dexlib2.dexbacked.DexBackedDexFile
|
||||
import androidx.core.net.toUri
|
||||
import com.follow.clash.R
|
||||
import com.follow.clash.common.Components
|
||||
import com.follow.clash.common.GlobalState
|
||||
import com.follow.clash.common.PendingCallback
|
||||
import com.follow.clash.common.QuickAction
|
||||
import com.follow.clash.common.quickIntent
|
||||
import com.follow.clash.common.registerReceiverCompat
|
||||
import com.follow.clash.getPackageIconPath
|
||||
import com.follow.clash.models.Package
|
||||
import com.follow.clash.packages.PackageResolver
|
||||
import com.follow.clash.showToast
|
||||
import com.google.gson.Gson
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.MethodChannel.Result
|
||||
import io.flutter.plugin.common.PluginRegistry
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware {
|
||||
|
||||
companion object {
|
||||
const val VPN_PERMISSION_REQUEST_CODE = 1001
|
||||
const val NOTIFICATION_PERMISSION_REQUEST_CODE = 1002
|
||||
}
|
||||
private var activity: Activity? = null
|
||||
|
||||
private var activityRef: WeakReference<Activity>? = null
|
||||
private var activityBinding: ActivityPluginBinding? = null
|
||||
|
||||
private val activityResultListener =
|
||||
PluginRegistry.ActivityResultListener(::onActivityResult)
|
||||
|
||||
private val permissionsResultListener =
|
||||
PluginRegistry.RequestPermissionsResultListener(::onRequestPermissionsResultListener)
|
||||
|
||||
private lateinit var channel: MethodChannel
|
||||
|
||||
private lateinit var scope: CoroutineScope
|
||||
|
||||
private var vpnPrepareCallback: (suspend () -> Unit)? = null
|
||||
private val vpnPrepareCallback = PendingCallback<Boolean>()
|
||||
|
||||
private var requestNotificationCallback: (() -> Unit)? = null
|
||||
private val requestNotificationCallback = PendingCallback<Boolean>()
|
||||
|
||||
private val packages = mutableListOf<Package>()
|
||||
private val requestInstalledAppsCallback = PendingCallback<Boolean>()
|
||||
|
||||
private val skipPrefixList = listOf(
|
||||
"com.google",
|
||||
"com.android.chrome",
|
||||
"com.android.vending",
|
||||
"com.microsoft",
|
||||
"com.apple",
|
||||
"com.zhiliaoapp.musically", // Banned by China
|
||||
)
|
||||
private val requestLocalNetworkCallback = PendingCallback<Boolean>()
|
||||
|
||||
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 var isRequestingNotificationPermission = false
|
||||
|
||||
private val chinaAppRegex by lazy {
|
||||
("(" + chinaAppPrefixList.joinToString("|").replace(".", "\\.") + ").*").toRegex()
|
||||
private var isRequestingLocalNetworkPermission = false
|
||||
|
||||
private var skipLocalNetworkPermissionRequest = false
|
||||
|
||||
private val gson = Gson()
|
||||
|
||||
private val packageResolver by lazy {
|
||||
PackageResolver(
|
||||
GlobalState.application.packageManager,
|
||||
GlobalState.application.packageName,
|
||||
)
|
||||
}
|
||||
|
||||
private var isBlockNotification: Boolean = false
|
||||
private var packageChangeContext: Context? = null
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: Result) {
|
||||
private val packageChangeReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent == null || intent.data?.schemeSpecificPart == GlobalState.application.packageName) {
|
||||
return
|
||||
}
|
||||
val addedByUpdate = intent.action == Intent.ACTION_PACKAGE_ADDED &&
|
||||
intent.getBooleanExtra(Intent.EXTRA_REPLACING, false)
|
||||
if (addedByUpdate) {
|
||||
return
|
||||
}
|
||||
packageResolver.invalidate()
|
||||
channel.invokeMethod("packagesChanged", null)
|
||||
}
|
||||
}
|
||||
|
||||
private var skipNotificationPermissionRequest = false
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
/**
|
||||
* The permission and consent hops below touch the Activity — starting an
|
||||
* activity for result, raising a permission prompt — and read the state that
|
||||
* tracks whether one is already up. Their callers are coroutines on
|
||||
* [Dispatchers.Default], while the answers come back on the main thread
|
||||
* through the ActivityAware listeners, so main is the one thread both ends
|
||||
* can agree on. A plain main-looper post rather than the plugin scope: the
|
||||
* scope is cancelled when the engine detaches, and a request dropped there
|
||||
* would leave its caller waiting for a callback that can no longer run.
|
||||
*/
|
||||
private fun onMainThread(block: () -> Unit) {
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
block()
|
||||
} else {
|
||||
mainHandler.post(block)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMethodCall(call: MethodCall, rawResult: Result) {
|
||||
val result = MainThreadResult(rawResult)
|
||||
when (call.method) {
|
||||
"moveTaskToBack" -> {
|
||||
activityRef?.get()?.moveTaskToBack(true)
|
||||
activity?.moveTaskToBack(true)
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
@@ -131,20 +139,29 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
||||
}
|
||||
|
||||
"initShortcuts" -> {
|
||||
initShortcuts(call.arguments as String)
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
"getPackages" -> {
|
||||
scope.launch {
|
||||
result.success(getPackagesToJson())
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
"getChinaPackageNames" -> {
|
||||
scope.launch {
|
||||
result.success(getChinaPackageNames())
|
||||
}
|
||||
"getPackages" -> reply(result) {
|
||||
gson.toJson(packageResolver.installedPackages)
|
||||
}
|
||||
|
||||
"getChinaPackageNames" -> reply(result) {
|
||||
gson.toJson(packageResolver.getChinaPackageNames())
|
||||
}
|
||||
|
||||
"isInstalledAppsPermissionGranted" -> reply(result) {
|
||||
packageResolver.hasInstalledAppsPermission()
|
||||
}
|
||||
|
||||
"requestInstalledAppsPermission" -> {
|
||||
requestInstalledAppsPermission { granted -> result.success(granted) }
|
||||
}
|
||||
|
||||
"getPackageIcon" -> {
|
||||
@@ -153,25 +170,51 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
||||
|
||||
"tip" -> {
|
||||
val message = call.argument<String>("message")
|
||||
tip(message)
|
||||
GlobalState.application.showToast(message)
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
"isBatteryOptimizationDisabled" -> {
|
||||
result.success(isBatteryOptimizationDisabled())
|
||||
}
|
||||
|
||||
"openBatteryOptimizationSettings" -> {
|
||||
result.success(openBatteryOptimizationSettings())
|
||||
}
|
||||
|
||||
"openAppSettings" -> {
|
||||
result.success(openAppSettings())
|
||||
}
|
||||
|
||||
"didCrashOnPreviousExecution" -> reply(result) {
|
||||
GlobalState.didCrashOnPreviousExecution()
|
||||
}
|
||||
|
||||
"getLastExitInfo" -> reply(result) {
|
||||
GlobalState.lastExitInfo()
|
||||
}
|
||||
|
||||
else -> {
|
||||
result.notImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleGetPackageIcon(call: MethodCall, result: Result) {
|
||||
scope.launch {
|
||||
val packageName = call.argument<String>("packageName")
|
||||
if (packageName == null) {
|
||||
result.success("")
|
||||
return@launch
|
||||
private fun handleGetPackageIcon(call: MethodCall, result: Result) = reply(result) {
|
||||
val packageName = call.argument<String>("packageName") ?: ""
|
||||
GlobalState.application.packageManager.getPackageIconPath(packageName)
|
||||
}
|
||||
|
||||
// A throw inside the plugin scope has no handler and ends the process;
|
||||
// the Dart caller gets a PlatformException to handle instead.
|
||||
private fun reply(result: Result, block: suspend () -> Any?) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
result.success(block())
|
||||
} catch (error: Exception) {
|
||||
GlobalState.log("Platform call failed: $error")
|
||||
result.error("PLATFORM_ERROR", error.toString(), null)
|
||||
}
|
||||
val path = GlobalState.application.packageManager.getPackageIconPath(packageName)
|
||||
result.success(path)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,237 +225,298 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
||||
IconCompat.createWithResource(
|
||||
GlobalState.application,
|
||||
R.mipmap.ic_launcher_round,
|
||||
)
|
||||
),
|
||||
)
|
||||
setIntent(QuickAction.TOGGLE.quickIntent)
|
||||
build()
|
||||
}
|
||||
ShortcutManagerCompat.setDynamicShortcuts(
|
||||
GlobalState.application, listOf(shortcut)
|
||||
GlobalState.application,
|
||||
listOf(shortcut),
|
||||
)
|
||||
}
|
||||
|
||||
private fun tip(message: String?) {
|
||||
GlobalState.application.showToast(message)
|
||||
private fun isBatteryOptimizationDisabled(): Boolean {
|
||||
val powerManager = getSystemService(GlobalState.application, PowerManager::class.java)
|
||||
return powerManager?.isIgnoringBatteryOptimizations(GlobalState.application.packageName)
|
||||
?: false
|
||||
}
|
||||
|
||||
@SuppressLint("BatteryLife")
|
||||
private fun openBatteryOptimizationSettings(): Boolean {
|
||||
// VPN continuity is the user-requested core function, so the direct exemption is intentional.
|
||||
val activity = activity ?: return false
|
||||
return try {
|
||||
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
|
||||
data = "package:${GlobalState.application.packageName}".toUri()
|
||||
}
|
||||
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 taskId = activity?.taskId ?: return
|
||||
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 == taskId
|
||||
} else {
|
||||
it.taskInfo.id == activityRef?.get()?.taskId
|
||||
it.taskInfo?.id == taskId
|
||||
}
|
||||
}
|
||||
|
||||
when (value) {
|
||||
true -> task?.setExcludeFromRecents(value)
|
||||
false -> task?.setExcludeFromRecents(value)
|
||||
null -> task?.setExcludeFromRecents(false)
|
||||
}
|
||||
task?.setExcludeFromRecents(value ?: false)
|
||||
}
|
||||
|
||||
|
||||
private fun getPackages(): List<Package> {
|
||||
val packageManager = GlobalState.application.packageManager
|
||||
if (packages.isNotEmpty()) return packages
|
||||
packageManager?.getInstalledPackages(PackageManager.GET_META_DATA or PackageManager.GET_PERMISSIONS)
|
||||
?.filter {
|
||||
it.packageName != GlobalState.application.packageName && it.packageName != "android"
|
||||
}?.map {
|
||||
Package(
|
||||
packageName = it.packageName,
|
||||
label = it.applicationInfo?.loadLabel(packageManager).toString(),
|
||||
system = (it.applicationInfo?.flags?.and(ApplicationInfo.FLAG_SYSTEM)) != 0,
|
||||
lastUpdateTime = it.lastUpdateTime,
|
||||
internet = it.requestedPermissions?.contains(Manifest.permission.INTERNET) == true
|
||||
)
|
||||
}?.let { packages.addAll(it) }
|
||||
return packages
|
||||
}
|
||||
|
||||
private suspend fun getPackagesToJson(): String {
|
||||
return withContext(Dispatchers.Default) {
|
||||
Gson().toJson(getPackages())
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getChinaPackageNames(): String {
|
||||
return withContext(Dispatchers.Default) {
|
||||
val packages: List<String> =
|
||||
getPackages().map { it.packageName }.filter { isChinaPackage(it) }
|
||||
Gson().toJson(packages)
|
||||
}
|
||||
}
|
||||
|
||||
fun requestNotificationsPermission(callBack: () -> Unit) {
|
||||
requestNotificationCallback = callBack
|
||||
fun requestNotificationPermission(callback: (Boolean) -> Unit) = onMainThread {
|
||||
requestNotificationCallback.replace(callback, supersededValue = false)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
val permission = ContextCompat.checkSelfPermission(
|
||||
GlobalState.application, Manifest.permission.POST_NOTIFICATIONS
|
||||
GlobalState.application,
|
||||
Manifest.permission.POST_NOTIFICATIONS,
|
||||
)
|
||||
if (permission == PackageManager.PERMISSION_GRANTED || isBlockNotification) {
|
||||
invokeRequestNotificationCallback()
|
||||
return
|
||||
if (permission == PackageManager.PERMISSION_GRANTED || skipNotificationPermissionRequest) {
|
||||
invokeRequestNotificationCallback(true)
|
||||
return@onMainThread
|
||||
}
|
||||
activityRef?.get()?.let {
|
||||
if (isRequestingNotificationPermission) {
|
||||
return@onMainThread
|
||||
}
|
||||
isRequestingNotificationPermission = true
|
||||
activity?.let {
|
||||
ActivityCompat.requestPermissions(
|
||||
it,
|
||||
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||
NOTIFICATION_PERMISSION_REQUEST_CODE
|
||||
NOTIFICATION_PERMISSION_REQUEST_CODE,
|
||||
)
|
||||
}
|
||||
return
|
||||
} else {
|
||||
invokeRequestNotificationCallback()
|
||||
} ?: invokeRequestNotificationCallback(true)
|
||||
return@onMainThread
|
||||
}
|
||||
|
||||
invokeRequestNotificationCallback(true)
|
||||
}
|
||||
|
||||
fun invokeRequestNotificationCallback() {
|
||||
requestNotificationCallback?.invoke()
|
||||
requestNotificationCallback = null
|
||||
private fun invokeRequestNotificationCallback(shouldStart: Boolean) {
|
||||
isRequestingNotificationPermission = false
|
||||
requestNotificationCallback.resolve(shouldStart)
|
||||
}
|
||||
|
||||
fun prepare(needPrepare: Boolean, callBack: (suspend () -> Unit)) {
|
||||
vpnPrepareCallback = callBack
|
||||
fun requestLocalNetworkPermission(callback: (Boolean) -> Unit) = onMainThread {
|
||||
requestLocalNetworkCallback.replace(callback, supersededValue = false)
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.CINNAMON_BUN || hasLocalNetworkPermission()) {
|
||||
invokeRequestLocalNetworkCallback(true)
|
||||
return@onMainThread
|
||||
}
|
||||
if (skipLocalNetworkPermissionRequest) {
|
||||
invokeRequestLocalNetworkCallback(false)
|
||||
return@onMainThread
|
||||
}
|
||||
if (isRequestingLocalNetworkPermission) {
|
||||
return@onMainThread
|
||||
}
|
||||
val activity = activity
|
||||
if (activity == null) {
|
||||
invokeRequestLocalNetworkCallback(false)
|
||||
return@onMainThread
|
||||
}
|
||||
isRequestingLocalNetworkPermission = true
|
||||
ActivityCompat.requestPermissions(
|
||||
activity,
|
||||
arrayOf(Manifest.permission.ACCESS_LOCAL_NETWORK),
|
||||
LOCAL_NETWORK_PERMISSION_REQUEST_CODE,
|
||||
)
|
||||
}
|
||||
|
||||
private fun hasLocalNetworkPermission(): Boolean = ContextCompat.checkSelfPermission(
|
||||
GlobalState.application,
|
||||
Manifest.permission.ACCESS_LOCAL_NETWORK,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
private fun invokeRequestLocalNetworkCallback(granted: Boolean) {
|
||||
isRequestingLocalNetworkPermission = false
|
||||
requestLocalNetworkCallback.resolve(granted)
|
||||
}
|
||||
|
||||
private fun requestInstalledAppsPermission(callback: (Boolean) -> Unit) = onMainThread {
|
||||
requestInstalledAppsCallback.replace(callback, supersededValue = false)
|
||||
val activity = activity
|
||||
if (packageResolver.hasInstalledAppsPermission()) {
|
||||
invokeRequestInstalledAppsCallback(true)
|
||||
return@onMainThread
|
||||
}
|
||||
if (activity == null) {
|
||||
invokeRequestInstalledAppsCallback(false)
|
||||
return@onMainThread
|
||||
}
|
||||
ActivityCompat.requestPermissions(
|
||||
activity,
|
||||
arrayOf(PackageResolver.GET_INSTALLED_APPS),
|
||||
INSTALLED_APPS_PERMISSION_REQUEST_CODE,
|
||||
)
|
||||
}
|
||||
|
||||
private fun invokeRequestInstalledAppsCallback(granted: Boolean) {
|
||||
if (granted) {
|
||||
packageResolver.invalidate()
|
||||
}
|
||||
requestInstalledAppsCallback.resolve(granted)
|
||||
}
|
||||
|
||||
fun prepareVpn(needPrepare: Boolean, callback: (Boolean) -> Unit) = onMainThread {
|
||||
vpnPrepareCallback.replace(callback, supersededValue = false)
|
||||
if (!needPrepare) {
|
||||
invokeVpnPrepareCallback()
|
||||
return
|
||||
invokeVpnPrepareCallback(true)
|
||||
return@onMainThread
|
||||
}
|
||||
val intent = VpnService.prepare(GlobalState.application)
|
||||
if (intent != null) {
|
||||
activityRef?.get()?.startActivityForResult(intent, VPN_PERMISSION_REQUEST_CODE)
|
||||
return
|
||||
}
|
||||
invokeVpnPrepareCallback()
|
||||
}
|
||||
|
||||
fun invokeVpnPrepareCallback() {
|
||||
GlobalState.launch {
|
||||
vpnPrepareCallback?.invoke()
|
||||
vpnPrepareCallback = null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun isChinaPackage(packageName: String): Boolean {
|
||||
val packageManager = GlobalState.application.packageManager ?: return false
|
||||
skipPrefixList.forEach {
|
||||
if (packageName == it || packageName.startsWith("$it.")) return false
|
||||
}
|
||||
val packageManagerFlags = 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 {
|
||||
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())
|
||||
)
|
||||
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@onMainThread
|
||||
}
|
||||
return false
|
||||
invokeVpnPrepareCallback(true)
|
||||
}
|
||||
|
||||
// Posted rather than run where the cancellation lands, so it stays ordered
|
||||
// behind the prepareVpn that installed the callback it is cancelling.
|
||||
fun cancelVpnPreparation(callback: (Boolean) -> Unit) = onMainThread {
|
||||
vpnPrepareCallback.cancel(callback)
|
||||
}
|
||||
|
||||
private fun invokeVpnPrepareCallback(granted: Boolean) {
|
||||
vpnPrepareCallback.resolve(granted)
|
||||
}
|
||||
|
||||
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
|
||||
scope = CoroutineScope(Dispatchers.Default)
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
channel =
|
||||
MethodChannel(flutterPluginBinding.binaryMessenger, "${Components.PACKAGE_NAME}/app")
|
||||
channel.setMethodCallHandler(this)
|
||||
watchPackageChanges(flutterPluginBinding.applicationContext)
|
||||
}
|
||||
|
||||
private fun watchPackageChanges(context: Context) {
|
||||
val filter = IntentFilter().apply {
|
||||
addAction(Intent.ACTION_PACKAGE_ADDED)
|
||||
addAction(Intent.ACTION_PACKAGE_REPLACED)
|
||||
addAction(Intent.ACTION_PACKAGE_FULLY_REMOVED)
|
||||
addDataScheme("package")
|
||||
}
|
||||
context.registerReceiverCompat(packageChangeReceiver, filter)
|
||||
packageChangeContext = context
|
||||
}
|
||||
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
packageChangeContext?.unregisterReceiver(packageChangeReceiver)
|
||||
packageChangeContext = null
|
||||
channel.setMethodCallHandler(null)
|
||||
scope.cancel()
|
||||
invokeVpnPrepareCallback(false)
|
||||
invokeRequestNotificationCallback(false)
|
||||
invokeRequestLocalNetworkCallback(false)
|
||||
invokeRequestInstalledAppsCallback(false)
|
||||
}
|
||||
|
||||
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||
activityRef = WeakReference(binding.activity)
|
||||
binding.addActivityResultListener(::onActivityResult)
|
||||
binding.addRequestPermissionsResultListener(::onRequestPermissionsResultListener)
|
||||
attachToActivity(binding)
|
||||
}
|
||||
|
||||
private fun attachToActivity(binding: ActivityPluginBinding) {
|
||||
detachFromActivity()
|
||||
activityBinding = binding
|
||||
activity = binding.activity
|
||||
binding.addActivityResultListener(activityResultListener)
|
||||
binding.addRequestPermissionsResultListener(permissionsResultListener)
|
||||
}
|
||||
|
||||
private fun detachFromActivity() {
|
||||
activity = null
|
||||
val binding = activityBinding ?: return
|
||||
activityBinding = null
|
||||
binding.removeActivityResultListener(activityResultListener)
|
||||
binding.removeRequestPermissionsResultListener(permissionsResultListener)
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivityForConfigChanges() {
|
||||
activityRef = null
|
||||
detachFromActivity()
|
||||
}
|
||||
|
||||
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
|
||||
activityRef = WeakReference(binding.activity)
|
||||
attachToActivity(binding)
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivity() {
|
||||
channel.invokeMethod("exit", null)
|
||||
activityRef = null
|
||||
detachFromActivity()
|
||||
invokeVpnPrepareCallback(false)
|
||||
invokeRequestNotificationCallback(false)
|
||||
invokeRequestLocalNetworkCallback(false)
|
||||
invokeRequestInstalledAppsCallback(false)
|
||||
}
|
||||
|
||||
private fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
|
||||
if (requestCode == VPN_PERMISSION_REQUEST_CODE) {
|
||||
if (resultCode == FlutterActivity.RESULT_OK) {
|
||||
invokeVpnPrepareCallback()
|
||||
}
|
||||
if (requestCode != VPN_PERMISSION_REQUEST_CODE) {
|
||||
return false
|
||||
}
|
||||
invokeVpnPrepareCallback(resultCode == Activity.RESULT_OK)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun onRequestPermissionsResultListener(
|
||||
requestCode: Int, permissions: Array<String>, grantResults: IntArray
|
||||
): Boolean {
|
||||
if (requestCode == NOTIFICATION_PERMISSION_REQUEST_CODE) {
|
||||
isBlockNotification = true
|
||||
requestCode: Int,
|
||||
permissions: Array<String>,
|
||||
grantResults: IntArray,
|
||||
): Boolean = when (requestCode) {
|
||||
NOTIFICATION_PERMISSION_REQUEST_CODE -> {
|
||||
skipNotificationPermissionRequest = true
|
||||
invokeRequestNotificationCallback(true)
|
||||
true
|
||||
}
|
||||
invokeRequestNotificationCallback()
|
||||
return true
|
||||
|
||||
INSTALLED_APPS_PERMISSION_REQUEST_CODE -> {
|
||||
invokeRequestInstalledAppsCallback(
|
||||
grantResults.isNotEmpty() &&
|
||||
grantResults[0] == PackageManager.PERMISSION_GRANTED,
|
||||
)
|
||||
true
|
||||
}
|
||||
|
||||
LOCAL_NETWORK_PERMISSION_REQUEST_CODE -> {
|
||||
skipLocalNetworkPermissionRequest = true
|
||||
invokeRequestLocalNetworkCallback(
|
||||
grantResults.isNotEmpty() &&
|
||||
grantResults[0] == PackageManager.PERMISSION_GRANTED,
|
||||
)
|
||||
true
|
||||
}
|
||||
|
||||
else -> false
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val VPN_PERMISSION_REQUEST_CODE = 1001
|
||||
const val NOTIFICATION_PERMISSION_REQUEST_CODE = 1002
|
||||
const val INSTALLED_APPS_PERMISSION_REQUEST_CODE = 1003
|
||||
const val LOCAL_NETWORK_PERMISSION_REQUEST_CODE = 1004
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.follow.clash.plugins
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
/**
|
||||
* Delivers a [MethodChannel.Result] on the main thread, which is where Flutter
|
||||
* requires it, from handlers that finish on a background dispatcher.
|
||||
*
|
||||
* The hop goes through the main looper rather than the plugin's coroutine scope:
|
||||
* that scope is cancelled when the engine detaches, and a reply dropped by a
|
||||
* cancelled scope leaves the Dart future waiting forever. A reply posted after
|
||||
* the engine is gone is the messenger's problem to ignore, and it does.
|
||||
*/
|
||||
internal class MainThreadResult(
|
||||
private val delegate: MethodChannel.Result,
|
||||
) : MethodChannel.Result {
|
||||
override fun success(result: Any?) = post { delegate.success(result) }
|
||||
|
||||
override fun error(code: String, message: String?, details: Any?) =
|
||||
post { delegate.error(code, message, details) }
|
||||
|
||||
override fun notImplemented() = post { delegate.notImplemented() }
|
||||
|
||||
private fun post(block: () -> Unit) {
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
block()
|
||||
} else {
|
||||
mainHandler.post(block)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val mainHandler = Handler(Looper.getMainLooper())
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
package com.follow.clash.plugins
|
||||
|
||||
import com.follow.clash.RunState
|
||||
import com.follow.clash.Service
|
||||
import com.follow.clash.State
|
||||
import com.follow.clash.ServiceController
|
||||
import com.follow.clash.ServiceState
|
||||
import com.follow.clash.common.Components
|
||||
import com.follow.clash.invokeMethodOnMainThread
|
||||
import com.follow.clash.models.SharedState
|
||||
import com.google.gson.Gson
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
@@ -13,127 +11,106 @@ import io.flutter.plugin.common.MethodChannel
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
|
||||
class ServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
|
||||
CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) {
|
||||
private lateinit var flutterMethodChannel: MethodChannel
|
||||
class ServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||
private lateinit var channel: MethodChannel
|
||||
private lateinit var scope: CoroutineScope
|
||||
private val gson = Gson()
|
||||
|
||||
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
|
||||
flutterMethodChannel = MethodChannel(
|
||||
flutterPluginBinding.binaryMessenger, "${Components.PACKAGE_NAME}/service"
|
||||
)
|
||||
flutterMethodChannel.setMethodCallHandler(this)
|
||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
channel = MethodChannel(binding.binaryMessenger, "${Components.PACKAGE_NAME}/service")
|
||||
channel.setMethodCallHandler(this)
|
||||
}
|
||||
|
||||
override fun onDetachedFromEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
|
||||
flutterMethodChannel.setMethodCallHandler(null)
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
channel.setMethodCallHandler(null)
|
||||
scope.cancel()
|
||||
ServiceController.setEventListener(null)
|
||||
}
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) = when (call.method) {
|
||||
"init" -> {
|
||||
handleInit(result)
|
||||
}
|
||||
|
||||
"shutdown" -> {
|
||||
handleShutdown(result)
|
||||
}
|
||||
|
||||
"invokeAction" -> {
|
||||
handleInvokeAction(call, result)
|
||||
}
|
||||
|
||||
"getRunTime" -> {
|
||||
handleGetRunTime(result)
|
||||
}
|
||||
|
||||
"syncState" -> {
|
||||
handleSyncState(call, result)
|
||||
}
|
||||
|
||||
"start" -> {
|
||||
handleStart(result)
|
||||
}
|
||||
|
||||
"stop" -> {
|
||||
handleStop(result)
|
||||
}
|
||||
|
||||
else -> {
|
||||
result.notImplemented()
|
||||
override fun onMethodCall(call: MethodCall, rawResult: MethodChannel.Result) {
|
||||
// Most handlers below reply from a scope worker on Dispatchers.Default,
|
||||
// but a MethodChannel.Result has to be answered on the platform thread.
|
||||
// Wrapping once here covers every branch, including notImplemented.
|
||||
val result = MainThreadResult(rawResult)
|
||||
when (call.method) {
|
||||
"init" -> initialize(result)
|
||||
"shutdown" -> shutdown(result)
|
||||
"invokeMethod" -> invokeMethod(call, result)
|
||||
"getRunTime" -> getRunTime(result)
|
||||
"syncState" -> syncState(call, result)
|
||||
"start" -> start(result)
|
||||
"stop" -> stop(result)
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleInvokeAction(call: MethodCall, result: MethodChannel.Result) {
|
||||
launch {
|
||||
val data = call.arguments<String>()!!
|
||||
Service.invokeAction(data) {
|
||||
result.success(it)
|
||||
}
|
||||
private fun initialize(result: MethodChannel.Result) {
|
||||
ServiceController.setEventListener(::sendEvent)
|
||||
.onSuccess { result.success("") }
|
||||
.onFailure { error -> result.success(error.message.orEmpty()) }
|
||||
}
|
||||
|
||||
private fun shutdown(result: MethodChannel.Result) {
|
||||
scope.launch {
|
||||
ServiceController.unbind()
|
||||
result.success(true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleShutdown(result: MethodChannel.Result) {
|
||||
Service.unbind()
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
private fun handleStart(result: MethodChannel.Result) {
|
||||
State.handleStartService()
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
private fun handleStop(result: MethodChannel.Result) {
|
||||
State.handleStopService()
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
val semaphore = Semaphore(10)
|
||||
|
||||
fun handleSendEvent(value: String?) {
|
||||
launch(Dispatchers.Main) {
|
||||
semaphore.withPermit {
|
||||
flutterMethodChannel.invokeMethod("event", value)
|
||||
}
|
||||
private fun 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
|
||||
}
|
||||
// Inline rather than a coroutine per call, which could swap two: watchRoute
|
||||
// keeps the latest intent by the order calls reach the Core
|
||||
// (dispatchMethodCall in core/method.go), and this only hands data over.
|
||||
ServiceController.invokeMethod(data) { response ->
|
||||
result.success(response)
|
||||
}.onFailure { error ->
|
||||
result.error("CORE_ERROR", error.message, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onServiceDisconnected(message: String) {
|
||||
State.runStateFlow.tryEmit(RunState.STOP)
|
||||
flutterMethodChannel.invokeMethodOnMainThread<Any>("crash", message)
|
||||
private fun getRunTime(result: MethodChannel.Result) {
|
||||
scope.launch {
|
||||
result.success(ServiceState.refresh())
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSyncState(call: MethodCall, result: MethodChannel.Result) {
|
||||
val data = call.arguments<String>()!!
|
||||
State.sharedState = Gson().fromJson(data, SharedState::class.java)
|
||||
launch {
|
||||
State.syncState()
|
||||
private fun syncState(call: MethodCall, result: MethodChannel.Result) {
|
||||
val data = call.arguments as? String
|
||||
val state = runCatching {
|
||||
gson.fromJson(data, SharedState::class.java)
|
||||
}.getOrNull()
|
||||
if (state == null) {
|
||||
result.success("Invalid shared state")
|
||||
return
|
||||
}
|
||||
scope.launch {
|
||||
ServiceState.syncSharedState(state)
|
||||
result.success("")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun handleInit(result: MethodChannel.Result) {
|
||||
Service.bind()
|
||||
launch {
|
||||
Service.setEventListener {
|
||||
handleSendEvent(it)
|
||||
}.onSuccess {
|
||||
result.success("")
|
||||
}.onFailure {
|
||||
result.success(it.message)
|
||||
}
|
||||
|
||||
}
|
||||
Service.onServiceDisconnected = ::onServiceDisconnected
|
||||
private fun start(result: MethodChannel.Result) {
|
||||
ServiceState.requestStart()
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
private fun handleGetRunTime(result: MethodChannel.Result) {
|
||||
launch {
|
||||
State.handleSyncState()
|
||||
result.success(State.runTime)
|
||||
private fun stop(result: MethodChannel.Result) {
|
||||
ServiceState.requestStop()
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
private fun sendEvent(value: String?) {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
channel.invokeMethod("event", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
class TilePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||
|
||||
private lateinit var channel: MethodChannel
|
||||
|
||||
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
|
||||
@@ -21,13 +20,14 @@ class TilePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||
}
|
||||
|
||||
fun handleStart() {
|
||||
channel.invokeMethodOnMainThread<Any>("start", null)
|
||||
channel.invokeMethodOnMainThread("start")
|
||||
}
|
||||
|
||||
fun handleStop() {
|
||||
channel.invokeMethodOnMainThread<Any>("stop", null)
|
||||
channel.invokeMethodOnMainThread("stop")
|
||||
}
|
||||
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {}
|
||||
}
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<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.9"
|
||||
android:scaleY="0.9"
|
||||
android:translateX="15.23"
|
||||
android:translateY="12.22">
|
||||
<path
|
||||
android:fillColor="#6666FB"
|
||||
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" />
|
||||
<path
|
||||
android:fillColor="#336AB6"
|
||||
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" />
|
||||
<path
|
||||
android:fillColor="#5CA8E9"
|
||||
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" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground_tv" />
|
||||
</adaptive-icon>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 782 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
@@ -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>
|
||||
@@ -15,10 +15,25 @@ subprojects {
|
||||
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||
}
|
||||
|
||||
subprojects {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
@@ -10,17 +9,7 @@ android {
|
||||
compileSdk = libs.versions.compileSdk.get().toInt()
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 21
|
||||
consumerProguardFiles("consumer-rules.pro")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
minSdk = libs.versions.minSdk.get().toInt()
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
@@ -28,6 +17,10 @@ android {
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
// Unit tests live under android/tests/ instead of each module's src/test.
|
||||
getByName("test").java.setSrcDirs(listOf("../tests/common"))
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
@@ -42,4 +35,5 @@ dependencies {
|
||||
implementation(platform(libs.firebase.bom))
|
||||
implementation(libs.firebase.crashlytics.ndk)
|
||||
implementation(libs.firebase.analytics)
|
||||
}
|
||||
testImplementation(libs.junit)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.follow.clash.common
|
||||
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* A `BroadcastReceiver.goAsync()` path holds the broadcast open until its
|
||||
* `PendingResult` is finished, and finishing twice throws. Normal completion and
|
||||
* the timeout watchdog both race to release it, so the winner is decided here
|
||||
* rather than by whichever callback happens to run first.
|
||||
*
|
||||
* Releasing the lease says only that Android may stop waiting for this receiver.
|
||||
* It does not cancel, reverse, or otherwise redefine the work the broadcast
|
||||
* started, which keeps running under its own owner.
|
||||
*/
|
||||
class BroadcastLease(private val release: () -> Unit) {
|
||||
private val released = AtomicBoolean(false)
|
||||
|
||||
val isReleased: Boolean
|
||||
get() = released.get()
|
||||
|
||||
fun release(onRelease: () -> Unit = {}): Boolean {
|
||||
if (!released.compareAndSet(false, true)) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
onRelease()
|
||||
} finally {
|
||||
release.invoke()
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,12 @@ import android.content.ComponentName
|
||||
object Components {
|
||||
const val PACKAGE_NAME = "com.follow.clash"
|
||||
|
||||
val MAIN_ACTIVITY =
|
||||
val mainActivity =
|
||||
ComponentName(GlobalState.packageName, "${PACKAGE_NAME}.MainActivity")
|
||||
|
||||
val TEMP_ACTIVITY =
|
||||
ComponentName(GlobalState.packageName, "${PACKAGE_NAME}.TempActivity")
|
||||
val quickActionActivity =
|
||||
ComponentName(GlobalState.packageName, "${PACKAGE_NAME}.QuickActionActivity")
|
||||
|
||||
val BROADCAST_RECEIVER =
|
||||
ComponentName(GlobalState.packageName, "${PACKAGE_NAME}.BroadcastReceiver")
|
||||
}
|
||||
val serviceBroadcastReceiver =
|
||||
ComponentName(GlobalState.packageName, "${PACKAGE_NAME}.ServiceBroadcastReceiver")
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.follow.clash.common
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
|
||||
enum class QuickAction {
|
||||
STOP,
|
||||
START,
|
||||
@@ -10,8 +9,8 @@ enum class QuickAction {
|
||||
}
|
||||
|
||||
enum class BroadcastAction {
|
||||
SERVICE_CREATED,
|
||||
SERVICE_DESTROYED,
|
||||
VPN_START_REQUESTED,
|
||||
VPN_REVOKED,
|
||||
}
|
||||
|
||||
enum class AccessControlMode {
|
||||
@@ -20,4 +19,4 @@ enum class AccessControlMode {
|
||||
|
||||
@SerializedName("rejectSelected")
|
||||
REJECT_SELECTED,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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
|
||||
@@ -13,55 +12,26 @@ import android.content.Context
|
||||
import android.content.Context.RECEIVER_NOT_EXPORTED
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.ServiceConnection
|
||||
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.os.RemoteException
|
||||
import android.util.Log
|
||||
import androidx.core.content.getSystemService
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.retryWhen
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.nio.charset.Charset
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
//fun Context.startForegroundServiceCompat(intent: Intent?) {
|
||||
// if (Build.VERSION.SDK_INT >= 26) {
|
||||
// startForegroundService(intent)
|
||||
// } else {
|
||||
// startService(intent)
|
||||
// }
|
||||
//}
|
||||
|
||||
val KClass<*>.intent: Intent
|
||||
get() = Intent(GlobalState.application, this.java)
|
||||
|
||||
fun Service.startForegroundCompat(id: Int, notification: Notification) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
startForeground(id, notification, FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
|
||||
} else {
|
||||
startForeground(id, notification)
|
||||
}
|
||||
}
|
||||
|
||||
val ComponentName.intent: Intent
|
||||
get() = Intent().apply {
|
||||
setComponent(this@intent)
|
||||
setPackage(GlobalState.packageName)
|
||||
component = this@intent
|
||||
}
|
||||
|
||||
val QuickAction.action: String
|
||||
get() = "${GlobalState.application.packageName}.action.${this.name}"
|
||||
|
||||
val QuickAction.quickIntent: Intent
|
||||
get() = Components.TEMP_ACTIVITY.intent.apply {
|
||||
get() = Components.quickActionActivity.intent.apply {
|
||||
action = this@quickIntent.action
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK)
|
||||
}
|
||||
@@ -69,56 +39,53 @@ val QuickAction.quickIntent: Intent
|
||||
val BroadcastAction.action: String
|
||||
get() = "${GlobalState.application.packageName}.intent.action.${this.name}"
|
||||
|
||||
val Context.processName: String?
|
||||
get() {
|
||||
val pid = android.os.Process.myPid()
|
||||
val activityManager = getSystemService<ActivityManager>()
|
||||
activityManager?.runningAppProcesses?.find { it.pid == pid }?.let {
|
||||
return it.processName
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
val BroadcastAction.quickIntent: Intent
|
||||
get() = Components.BROADCAST_RECEIVER.intent.apply {
|
||||
action = this@quickIntent.action
|
||||
}
|
||||
|
||||
fun BroadcastAction.sendBroadcast() {
|
||||
val intent = Intent().apply {
|
||||
action = this@sendBroadcast.action
|
||||
Log.d("[sendBroadcast]", "$action")
|
||||
setPackage(GlobalState.packageName)
|
||||
val broadcastAction = action
|
||||
val intent = Intent(broadcastAction).apply {
|
||||
component = Components.serviceBroadcastReceiver
|
||||
}
|
||||
GlobalState.log("Send broadcast: $broadcastAction")
|
||||
GlobalState.application.sendBroadcast(
|
||||
intent, GlobalState.RECEIVE_BROADCASTS_PERMISSIONS
|
||||
intent,
|
||||
GlobalState.receiveBroadcastPermission,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
val Intent.toPendingIntent: PendingIntent
|
||||
get() = PendingIntent.getActivity(
|
||||
GlobalState.application,
|
||||
0,
|
||||
this,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
|
||||
fun Context.ensureNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
return
|
||||
}
|
||||
val manager = getSystemService(NotificationManager::class.java) ?: return
|
||||
if (manager.getNotificationChannel(GlobalState.NOTIFICATION_CHANNEL) != null) {
|
||||
return
|
||||
}
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
GlobalState.NOTIFICATION_CHANNEL,
|
||||
getString(R.string.service_channel_name),
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
"SERVICE_CHANNEL",
|
||||
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)
|
||||
}
|
||||
startForegroundCompat(GlobalState.NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
@SuppressLint("UnspecifiedRegisterReceiverFlag")
|
||||
@@ -144,107 +111,3 @@ fun Context.receiveBroadcastFlow(
|
||||
registerReceiverCompat(receiver, filter)
|
||||
awaitClose { unregisterReceiver(receiver) }
|
||||
}
|
||||
|
||||
|
||||
inline fun <reified T : IBinder> Context.bindServiceFlow(
|
||||
intent: Intent,
|
||||
flags: Int = Context.BIND_AUTO_CREATE,
|
||||
maxRetries: Int = 10,
|
||||
retryDelayMillis: Long = 200L
|
||||
): Flow<Pair<IBinder?, String>> = callbackFlow {
|
||||
val connection = object : ServiceConnection {
|
||||
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
|
||||
if (binder != null) {
|
||||
try {
|
||||
@Suppress("UNCHECKED_CAST") val casted = binder as? T
|
||||
if (casted != null) {
|
||||
trySend(Pair(casted, ""))
|
||||
} else {
|
||||
trySend(Pair(null, "Binder is not of type ${T::class.java}"))
|
||||
}
|
||||
} catch (e: RemoteException) {
|
||||
trySend(Pair(null, "Failed to link to death: ${e.message}"))
|
||||
}
|
||||
} else {
|
||||
trySend(Pair(null, "Binder empty"))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
trySend(Pair(null, "Service disconnected"))
|
||||
}
|
||||
}
|
||||
|
||||
val success = withContext(Dispatchers.Main) {
|
||||
bindService(intent, connection, flags)
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
throw IllegalStateException("bindService() failed, will retry")
|
||||
}
|
||||
|
||||
awaitClose {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
unbindService(connection)
|
||||
trySend(Pair(null, ""))
|
||||
}
|
||||
}
|
||||
}.retryWhen { cause, attempt ->
|
||||
if (attempt < maxRetries && cause is Exception) {
|
||||
delay(retryDelayMillis)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
val Long.formatBytes: String
|
||||
get() {
|
||||
val units = arrayOf("B", "KB", "MB", "GB", "TB")
|
||||
var size = this.toDouble()
|
||||
var unitIndex = 0
|
||||
|
||||
while (size >= 1024 && unitIndex < units.size - 1) {
|
||||
size /= 1024
|
||||
unitIndex++
|
||||
}
|
||||
|
||||
return if (unitIndex == 0) {
|
||||
"${size.toLong()}${units[unitIndex]}"
|
||||
} else {
|
||||
"%.1f${units[unitIndex]}".format(size)
|
||||
}
|
||||
}
|
||||
|
||||
fun String.chunkedForAidl(charset: Charset = Charsets.UTF_8): List<ByteArray> {
|
||||
val allBytes = toByteArray(charset)
|
||||
val total = allBytes.size
|
||||
val maxBytes = when {
|
||||
total <= 100 * 1024 -> total
|
||||
total <= 1024 * 1024 -> 64 * 1024
|
||||
total <= 10 * 1024 * 1024 -> 128 * 1024
|
||||
else -> 256 * 1024
|
||||
}
|
||||
|
||||
val result = mutableListOf<ByteArray>()
|
||||
var index = 0
|
||||
while (index < total) {
|
||||
val end = minOf(index + maxBytes, total)
|
||||
result.add(allBytes.copyOfRange(index, end))
|
||||
index = end
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
fun <T : List<ByteArray>> T.formatString(charset: Charset = Charsets.UTF_8): String {
|
||||
val totalSize = this.sumOf { it.size }
|
||||
val combined = ByteArray(totalSize)
|
||||
var offset = 0
|
||||
forEach { byteArray ->
|
||||
byteArray.copyInto(combined, offset)
|
||||
offset += byteArray.size
|
||||
}
|
||||
return String(combined, charset)
|
||||
}
|
||||
@@ -1,47 +1,68 @@
|
||||
package com.follow.clash.common
|
||||
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.app.Application
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import com.google.firebase.FirebaseApp
|
||||
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
|
||||
object GlobalState : CoroutineScope by CoroutineScope(Dispatchers.Default) {
|
||||
|
||||
object GlobalState : CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) {
|
||||
const val NOTIFICATION_CHANNEL = "FlClash"
|
||||
|
||||
const val NOTIFICATION_ID = 1
|
||||
private const val ANY_PID = 0
|
||||
private const val EVERY_EXIT_RECORD = 0
|
||||
|
||||
val packageName: String
|
||||
get() = application.packageName
|
||||
|
||||
val RECEIVE_BROADCASTS_PERMISSIONS: String
|
||||
get() = "${packageName}.permission.RECEIVE_BROADCASTS"
|
||||
|
||||
|
||||
private var _application: Application? = null
|
||||
val receiveBroadcastPermission: String
|
||||
get() = "$packageName.permission.RECEIVE_BROADCASTS"
|
||||
|
||||
val application: Application
|
||||
get() = _application!!
|
||||
get() = checkNotNull(appInstance) { "GlobalState is not initialized" }
|
||||
|
||||
|
||||
fun log(text: String) {
|
||||
Log.d("[FlClash]", text)
|
||||
}
|
||||
@Volatile
|
||||
private var appInstance: Application? = null
|
||||
|
||||
fun init(application: Application) {
|
||||
_application = application
|
||||
appInstance = application
|
||||
}
|
||||
|
||||
fun log(text: String) {
|
||||
Log.d("FlClash", text)
|
||||
}
|
||||
|
||||
fun setCrashlytics(enable: Boolean) {
|
||||
_application?.let {
|
||||
FirebaseApp.initializeApp(it)
|
||||
FirebaseCrashlytics.getInstance().isCrashlyticsCollectionEnabled = enable
|
||||
if (enable) {
|
||||
log("init crashlytics ${it.processName}")
|
||||
}
|
||||
FirebaseApp.initializeApp(application)
|
||||
FirebaseCrashlytics.getInstance().isCrashlyticsCollectionEnabled = enable
|
||||
if (enable) {
|
||||
log("Crashlytics enabled")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun didCrashOnPreviousExecution(): Boolean {
|
||||
FirebaseApp.initializeApp(application)
|
||||
return FirebaseCrashlytics.getInstance().didCrashOnPreviousExecution()
|
||||
}
|
||||
|
||||
fun lastExitInfo(): Map<String, Any?>? {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return null
|
||||
val manager = application.getSystemService(ActivityManager::class.java) ?: return null
|
||||
val info = runCatching {
|
||||
manager.getHistoricalProcessExitReasons(
|
||||
application.packageName,
|
||||
ANY_PID,
|
||||
EVERY_EXIT_RECORD,
|
||||
)
|
||||
}.getOrNull()?.firstOrNull { it.processName == application.packageName } ?: return null
|
||||
return mapOf(
|
||||
"reason" to info.reason,
|
||||
"timestamp" to info.timestamp,
|
||||
"description" to info.description,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.follow.clash.common
|
||||
|
||||
/**
|
||||
* Only the newest request is ever pending: [replace] settles the request it
|
||||
* supersedes rather than dropping it, so no caller is left waiting forever. The
|
||||
* slot is cleared before the callback runs, so a callback that starts another
|
||||
* request cannot resolve itself twice.
|
||||
*
|
||||
* The two ends of such a hop live on different threads — the request is started
|
||||
* from whatever coroutine wants the permission, the answer arrives on the main
|
||||
* thread through `onActivityResult` — so every read and write of the slot is a
|
||||
* single atomic swap. Without that, a resolve that has already read the slot can
|
||||
* clear a callback a concurrent replace installed after it, and the request that
|
||||
* callback belonged to never completes: the VPN consent answer is lost and the
|
||||
* lock the start path holds is never released.
|
||||
*
|
||||
* Callbacks run outside the lock. They resume coroutines and can start the next
|
||||
* request, and holding the monitor across that would make the slot's lock part
|
||||
* of every caller's lock order.
|
||||
*/
|
||||
class PendingCallback<T> {
|
||||
private val lock = Any()
|
||||
private var callback: ((T) -> Unit)? = null
|
||||
|
||||
val isPending: Boolean
|
||||
get() = synchronized(lock) { callback != null }
|
||||
|
||||
fun replace(next: (T) -> Unit, supersededValue: T) {
|
||||
val superseded = synchronized(lock) {
|
||||
val current = callback
|
||||
callback = next
|
||||
current
|
||||
}
|
||||
superseded?.invoke(supersededValue)
|
||||
}
|
||||
|
||||
fun resolve(value: T) {
|
||||
val current = synchronized(lock) {
|
||||
val current = callback ?: return
|
||||
callback = null
|
||||
current
|
||||
}
|
||||
current(value)
|
||||
}
|
||||
|
||||
fun cancel(target: (T) -> Unit) {
|
||||
synchronized(lock) {
|
||||
if (callback === target) {
|
||||
callback = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.follow.clash.common
|
||||
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/**
|
||||
* Every request mints a fresh [Token]. Work that was started for an older token is obsolete once a
|
||||
* newer request arrives, so callers check [isCurrent] before applying a result and roll back with
|
||||
* [resetToStopped], which only wins while the token is still the latest one.
|
||||
*/
|
||||
class RunIntentArbiter(initialRunning: Boolean = false) {
|
||||
class Token internal constructor(
|
||||
val running: Boolean,
|
||||
)
|
||||
|
||||
private val latest = AtomicReference(Token(initialRunning))
|
||||
|
||||
val isRunningRequested: Boolean
|
||||
get() = latest.get().running
|
||||
|
||||
fun current(): Token = latest.get()
|
||||
|
||||
fun request(running: Boolean): Token = Token(running).also(latest::set)
|
||||
|
||||
fun isCurrent(token: Token): Boolean = latest.get() === token
|
||||
|
||||
fun resetToStopped(token: Token): Boolean =
|
||||
latest.compareAndSet(token, Token(running = false))
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
package com.follow.clash.common
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
class ServiceDelegate<T>(
|
||||
private val intent: Intent,
|
||||
private val onServiceDisconnected: ((String) -> Unit)? = null,
|
||||
private val interfaceCreator: (IBinder) -> T,
|
||||
) : CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) {
|
||||
|
||||
private val _bindingState = AtomicBoolean(false)
|
||||
|
||||
private var _serviceState = MutableStateFlow<Pair<T?, String>?>(null)
|
||||
|
||||
val serviceState: StateFlow<Pair<T?, String>?> = _serviceState
|
||||
private var job: Job? = null
|
||||
|
||||
private fun handleBind(data: Pair<IBinder?, String>) {
|
||||
data.first?.let {
|
||||
_serviceState.value = Pair(interfaceCreator(it), data.second)
|
||||
} ?: run {
|
||||
_serviceState.value = Pair(null, data.second)
|
||||
unbind()
|
||||
onServiceDisconnected?.invoke(data.second)
|
||||
_bindingState.set(false)
|
||||
}
|
||||
}
|
||||
|
||||
fun bind() {
|
||||
if (_bindingState.compareAndSet(false, true)) {
|
||||
job?.cancel()
|
||||
job = null
|
||||
_serviceState.value = null
|
||||
job = launch {
|
||||
runCatching {
|
||||
GlobalState.application.bindServiceFlow<IBinder>(intent)
|
||||
.collect { handleBind(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend inline fun <R> useService(
|
||||
timeoutMillis: Long = 5000, crossinline block: suspend (T) -> R
|
||||
): Result<R> {
|
||||
return runCatching {
|
||||
withTimeout(timeoutMillis) {
|
||||
val state = serviceState.filterNotNull().first()
|
||||
state.first?.let {
|
||||
withContext(Dispatchers.Default) {
|
||||
block(it)
|
||||
}
|
||||
} ?: throw Exception(state.second)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun unbind() {
|
||||
if (_bindingState.compareAndSet(true, false)) {
|
||||
job?.cancel()
|
||||
job = null
|
||||
_serviceState.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package com.follow.clash.common
|
||||
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
|
||||
|
||||
fun tickerFlow(delayMillis: Long, initialDelayMillis: Long = delayMillis): Flow<Unit> = flow {
|
||||
delay(initialDelayMillis)
|
||||
while (true) {
|
||||
emit(Unit)
|
||||
delay(delayMillis)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="FlClash">FlClash</string>
|
||||
<string name="app_name">FlClash</string>
|
||||
<string name="service_channel_name">FlClash Service</string>
|
||||
</resources>
|
||||
|
||||
@@ -2,9 +2,17 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
// Must equal Target.forPlatform("android") in setup_hooks, which writes libclash.so only for the platforms being built.
|
||||
val coreAbiByPlatform =
|
||||
linkedMapOf("android-arm" to "armeabi-v7a", "android-arm64" to "arm64-v8a", "android-x64" to "x86_64")
|
||||
val coreAbis =
|
||||
(rootProject.findProperty("target-platform") as String?)
|
||||
?.split(",")
|
||||
?.map { coreAbiByPlatform[it.trim()] ?: throw GradleException("No Core for Flutter target platform $it") }
|
||||
?: coreAbiByPlatform.values.toList()
|
||||
|
||||
android {
|
||||
namespace = "com.follow.clash.core"
|
||||
compileSdk = libs.versions.compileSdk.get().toInt()
|
||||
@@ -12,12 +20,9 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
minSdk = libs.versions.minSdk.get().toInt()
|
||||
}
|
||||
|
||||
|
||||
sourceSets {
|
||||
getByName("main") {
|
||||
jniLibs.srcDirs("src/main/jniLibs")
|
||||
ndk {
|
||||
abiFilters += coreAbis
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,19 +33,18 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
// :app strips what it packages; Crashlytics symbolication needs the unstripped libraries to reach its merge.
|
||||
packaging {
|
||||
jniLibs {
|
||||
keepDebugSymbols += "**/*.so"
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
@@ -49,33 +53,42 @@ kotlin {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
dependencies {
|
||||
implementation(libs.annotation.jvm)
|
||||
}
|
||||
|
||||
val copyNativeLibs by tasks.register<Copy>("copyNativeLibs") {
|
||||
doFirst {
|
||||
delete("src/main/jniLibs")
|
||||
}
|
||||
from("../../libclash/android")
|
||||
into("src/main/jniLibs")
|
||||
// Why prefix matching and mustRunAfter: .agents/architecture.md, "Android Native Task Ordering".
|
||||
val nativeTaskPattern =
|
||||
Regex("^(configureCMake|buildCMake|externalNativeBuild|merge.*(NativeLibs|JniLibFolders)|copy.*JniLibs)")
|
||||
val flutterCompileTasks =
|
||||
rootProject.project(":app").tasks.matching { it.name.startsWith("compileFlutterBuild") }
|
||||
|
||||
doLast {
|
||||
val includesDir = file("src/main/jniLibs/includes")
|
||||
val targetDir = file("src/main/cpp/includes")
|
||||
if (includesDir.exists()) {
|
||||
copy {
|
||||
from(includesDir)
|
||||
into(targetDir)
|
||||
}
|
||||
delete(includesDir)
|
||||
}
|
||||
}
|
||||
tasks.matching { nativeTaskPattern.containsMatchIn(it.name) }.configureEach {
|
||||
mustRunAfter(flutterCompileTasks)
|
||||
}
|
||||
|
||||
afterEvaluate {
|
||||
tasks.named("preBuild") {
|
||||
dependsOn(copyNativeLibs)
|
||||
gradle.taskGraph.whenReady {
|
||||
val nativeTask =
|
||||
allTasks.firstOrNull {
|
||||
it.path.startsWith("${project.path}:") && nativeTaskPattern.containsMatchIn(it.name)
|
||||
}
|
||||
if (nativeTask == null || allTasks.any { it.path.startsWith(":app:compileFlutterBuild") }) {
|
||||
return@whenReady
|
||||
}
|
||||
}
|
||||
val missing =
|
||||
coreAbis
|
||||
.flatMap {
|
||||
listOf(file("src/main/jniLibs/$it/libclash.so"), file("src/main/cpp/includes/$it/libclash.h"))
|
||||
}.filterNot { it.isFile }
|
||||
if (missing.isNotEmpty()) {
|
||||
throw GradleException(
|
||||
"${nativeTask.name} consumes Core artifacts that the setup build hook writes into src/main, " +
|
||||
"and these are absent: ${missing.joinToString { it.relativeTo(projectDir).path }}. " +
|
||||
"Run `dart setup.dart android` once, or build through :app so the hook runs first.",
|
||||
)
|
||||
}
|
||||
logger.warn(
|
||||
":core: ${nativeTask.name} is running without an :app Flutter compile task, " +
|
||||
"so the Core artifacts under src/main are whatever the last hook run left behind",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,27 +9,20 @@ 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)
|
||||
add_compile_options(-O3 -flto -g -fno-exceptions -fno-rtti)
|
||||
add_link_options(-flto -Wl,--gc-sections)
|
||||
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}")
|
||||
|
||||
message("LIB_CLASH_PATH ${LIB_CLASH_PATH}")
|
||||
if (EXISTS ${LIB_CLASH_PATH})
|
||||
message("Found libclash.so for ABI ${ANDROID_ABI}")
|
||||
add_compile_definitions(LIBCLASH)
|
||||
include_directories(${CMAKE_SOURCE_DIR}/../cpp/includes/${ANDROID_ABI})
|
||||
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 libclash.so for ABI ${ANDROID_ABI}")
|
||||
add_library(${CMAKE_PROJECT_NAME} SHARED
|
||||
jni_helper.cpp
|
||||
core.cpp)
|
||||
target_link_libraries(${CMAKE_PROJECT_NAME})
|
||||
endif ()
|
||||
# Never make linking libclash conditional on the file existing: AGP's configure
|
||||
# fingerprint tracks only CMakeLists.txt, so a configure that ran before the build
|
||||
# hook would stay cached and silently drop the Core. Failing at compile or link
|
||||
# time is what keeps that decision out of the configure step.
|
||||
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)
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
#include <jni.h>
|
||||
|
||||
#ifdef LIBCLASH
|
||||
#include <cstring>
|
||||
|
||||
#include "jni_helper.h"
|
||||
#include "libclash.h"
|
||||
#include "bride.h"
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT void JNICALL
|
||||
JNIEXPORT jboolean 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));
|
||||
return startTUN(interface, fd, get_string(stack), get_string(address), get_string(dns))
|
||||
? JNI_TRUE
|
||||
: JNI_FALSE;
|
||||
}
|
||||
|
||||
extern "C"
|
||||
@@ -34,9 +36,9 @@ Java_com_follow_clash_core_Core_updateDNS(JNIEnv *env, jobject thiz, jstring dns
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_follow_clash_core_Core_invokeAction(JNIEnv *env, jobject thiz, jstring data, jobject cb) {
|
||||
Java_com_follow_clash_core_Core_invokeMethod(JNIEnv *env, jobject thiz, jstring data, jobject cb) {
|
||||
const auto interface = new_global(cb);
|
||||
invokeAction(interface, get_string(data));
|
||||
invokeMethod(interface, get_string(data));
|
||||
}
|
||||
|
||||
extern "C"
|
||||
@@ -54,20 +56,16 @@ extern "C"
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_com_follow_clash_core_Core_getTraffic(JNIEnv *env, jobject thiz,
|
||||
const jboolean only_statistics_proxy) {
|
||||
return new_string(getTraffic(only_statistics_proxy));
|
||||
scoped_string traffic = getTraffic(only_statistics_proxy);
|
||||
return new_string(traffic);
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_com_follow_clash_core_Core_getTotalTraffic(JNIEnv *env, jobject thiz,
|
||||
const jboolean only_statistics_proxy) {
|
||||
return new_string(getTotalTraffic(only_statistics_proxy));
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_follow_clash_core_Core_suspended(JNIEnv *env, jobject thiz, jboolean suspended) {
|
||||
suspend(suspended);
|
||||
scoped_string traffic = getTotalTraffic(only_statistics_proxy);
|
||||
return new_string(traffic);
|
||||
}
|
||||
|
||||
extern "C"
|
||||
@@ -80,7 +78,8 @@ Java_com_follow_clash_core_Core_quickSetup(JNIEnv *env, jobject thiz, jstring in
|
||||
|
||||
|
||||
static jmethodID m_tun_interface_protect;
|
||||
static jmethodID m_tun_interface_resolve_process;
|
||||
static jmethodID m_tun_interface_resolve_uid;
|
||||
static jmethodID m_tun_interface_resolve_package;
|
||||
static jmethodID m_invoke_interface_result;
|
||||
|
||||
|
||||
@@ -93,34 +92,76 @@ static void free_string_impl(char *str) {
|
||||
free(str);
|
||||
}
|
||||
|
||||
static void call_tun_interface_protect_impl(void *tun_interface, const int fd) {
|
||||
static int call_tun_interface_protect_impl(void *tun_interface, const int fd) {
|
||||
// ART aborts the process on a call through a null object, so a callback
|
||||
// that was already released has to stop here rather than at the JNI call.
|
||||
if (tun_interface == nullptr) {
|
||||
return 0;
|
||||
}
|
||||
ATTACH_JNI();
|
||||
env->CallVoidMethod(static_cast<jobject>(tun_interface),
|
||||
m_tun_interface_protect,
|
||||
fd);
|
||||
const auto accepted = env->CallBooleanMethod(static_cast<jobject>(tun_interface),
|
||||
m_tun_interface_protect,
|
||||
fd);
|
||||
if (jni_clear_exception(env)) {
|
||||
return 0;
|
||||
}
|
||||
return accepted == JNI_TRUE ? 1 : 0;
|
||||
}
|
||||
|
||||
static char *
|
||||
call_tun_interface_resolve_process_impl(void *tun_interface, const int protocol,
|
||||
const char *source,
|
||||
const char *target,
|
||||
const int uid) {
|
||||
static int call_tun_interface_resolve_uid_impl(void *tun_interface, const int protocol,
|
||||
const char *source,
|
||||
const char *target) {
|
||||
if (tun_interface == nullptr) {
|
||||
return -1;
|
||||
}
|
||||
ATTACH_JNI();
|
||||
const auto packageName = reinterpret_cast<jstring>(env->CallObjectMethod(
|
||||
const auto source_string = new_string(source);
|
||||
const auto target_string = new_string(target);
|
||||
const auto uid = env->CallIntMethod(static_cast<jobject>(tun_interface),
|
||||
m_tun_interface_resolve_uid,
|
||||
protocol,
|
||||
source_string,
|
||||
target_string);
|
||||
const auto failed = jni_clear_exception(env);
|
||||
if (source_string != nullptr) {
|
||||
env->DeleteLocalRef(source_string);
|
||||
}
|
||||
if (target_string != nullptr) {
|
||||
env->DeleteLocalRef(target_string);
|
||||
}
|
||||
return failed ? -1 : uid;
|
||||
}
|
||||
|
||||
static char *call_tun_interface_resolve_package_impl(void *tun_interface, const int uid) {
|
||||
if (tun_interface == nullptr) {
|
||||
return strdup("");
|
||||
}
|
||||
ATTACH_JNI();
|
||||
const auto package_name = reinterpret_cast<jstring>(env->CallObjectMethod(
|
||||
static_cast<jobject>(tun_interface),
|
||||
m_tun_interface_resolve_process,
|
||||
protocol,
|
||||
new_string(source),
|
||||
new_string(target),
|
||||
m_tun_interface_resolve_package,
|
||||
uid));
|
||||
return get_string(packageName);
|
||||
jni_clear_exception(env);
|
||||
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) {
|
||||
if (invoke_interface == nullptr) {
|
||||
return;
|
||||
}
|
||||
ATTACH_JNI();
|
||||
const auto value = new_bytes(data);
|
||||
env->CallVoidMethod(static_cast<jobject>(invoke_interface),
|
||||
m_invoke_interface_result,
|
||||
new_string(data));
|
||||
value);
|
||||
jni_clear_exception(env);
|
||||
if (value != nullptr) {
|
||||
env->DeleteLocalRef(value);
|
||||
}
|
||||
}
|
||||
|
||||
extern "C"
|
||||
@@ -137,72 +178,21 @@ JNI_OnLoad(JavaVM *vm, void *) {
|
||||
|
||||
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_tun_interface_protect = find_method(c_tun_interface, "protect", "(I)Z");
|
||||
m_tun_interface_resolve_uid = find_method(c_tun_interface, "resolveUid",
|
||||
"(ILjava/lang/String;Ljava/lang/String;)I");
|
||||
m_tun_interface_resolve_package = find_method(c_tun_interface, "resolvePackage",
|
||||
"(I)Ljava/lang/String;");
|
||||
m_invoke_interface_result = find_method(c_invoke_interface, "onResult",
|
||||
"(Ljava/lang/String;)V");
|
||||
"([B)V");
|
||||
|
||||
|
||||
protect_func = &call_tun_interface_protect_impl;
|
||||
resolve_process_func = &call_tun_interface_resolve_process_impl;
|
||||
resolve_uid_func = &call_tun_interface_resolve_uid_impl;
|
||||
resolve_package_func = &call_tun_interface_resolve_package_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_invokeAction(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) {
|
||||
}
|
||||
extern "C"
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_com_follow_clash_core_Core_getTotalTraffic(JNIEnv *env, jobject thiz,
|
||||
const jboolean only_statistics_proxy) {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -1,8 +1,8 @@
|
||||
#include "jni_helper.h"
|
||||
|
||||
#include <cstdlib>
|
||||
#include <malloc.h>
|
||||
#include <cstring>
|
||||
#include <pthread.h>
|
||||
|
||||
static JavaVM *global_vm;
|
||||
|
||||
@@ -10,62 +10,124 @@ static jclass c_string;
|
||||
static jmethodID m_new_string;
|
||||
static jmethodID m_get_bytes;
|
||||
|
||||
static pthread_key_t detach_key;
|
||||
static bool detach_key_ready;
|
||||
|
||||
static void detach_current_thread(void *) {
|
||||
global_vm->DetachCurrentThread();
|
||||
}
|
||||
|
||||
void initialize_jni(JavaVM *vm, JNIEnv *env) {
|
||||
global_vm = vm;
|
||||
|
||||
detach_key_ready = pthread_key_create(&detach_key, detach_current_thread) == 0;
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
JavaVM *global_java_vm() {
|
||||
return global_vm;
|
||||
bool jni_clear_exception(JNIEnv *env) {
|
||||
if (env->ExceptionCheck() == JNI_FALSE) {
|
||||
return false;
|
||||
}
|
||||
env->ExceptionDescribe();
|
||||
env->ExceptionClear();
|
||||
return true;
|
||||
}
|
||||
|
||||
static char *empty_string() {
|
||||
return static_cast<char *>(calloc(1, 1));
|
||||
}
|
||||
|
||||
char *jni_get_string(JNIEnv *env, jstring str) {
|
||||
if (str == nullptr) {
|
||||
return empty_string();
|
||||
}
|
||||
const auto array = reinterpret_cast<jbyteArray>(env->CallObjectMethod(str, m_get_bytes));
|
||||
if (jni_clear_exception(env) || array == nullptr) {
|
||||
return empty_string();
|
||||
}
|
||||
const int length = env->GetArrayLength(array);
|
||||
const auto content = static_cast<char *>(malloc(length + 1));
|
||||
if (content == nullptr) {
|
||||
env->DeleteLocalRef(array);
|
||||
return empty_string();
|
||||
}
|
||||
env->GetByteArrayRegion(array, 0, length, reinterpret_cast<jbyte *>(content));
|
||||
if (jni_clear_exception(env)) {
|
||||
// The copy did not happen, so `content` still holds whatever malloc
|
||||
// handed back. Returning it would pass that heap content on as the
|
||||
// string the caller asked for.
|
||||
free(content);
|
||||
env->DeleteLocalRef(array);
|
||||
return empty_string();
|
||||
}
|
||||
env->DeleteLocalRef(array);
|
||||
content[length] = 0;
|
||||
return content;
|
||||
}
|
||||
|
||||
jstring jni_new_string(JNIEnv *env, const char *str) {
|
||||
if (str == nullptr) {
|
||||
str = "";
|
||||
}
|
||||
const auto length = static_cast<int>(strlen(str));
|
||||
const auto array = env->NewByteArray(length);
|
||||
if (jni_clear_exception(env) || array == nullptr) {
|
||||
return env->NewStringUTF("");
|
||||
}
|
||||
env->SetByteArrayRegion(array, 0, length, reinterpret_cast<const jbyte *>(str));
|
||||
return reinterpret_cast<jstring>(env->NewObject(c_string, m_new_string, array));
|
||||
}
|
||||
|
||||
int jni_catch_exception(JNIEnv *env) {
|
||||
const int result = env->ExceptionCheck();
|
||||
if (result) {
|
||||
env->ExceptionDescribe();
|
||||
env->ExceptionClear();
|
||||
if (jni_clear_exception(env)) {
|
||||
// Calling NewObject with an exception still pending is undefined, and
|
||||
// the array it would read from was not filled in anyway.
|
||||
env->DeleteLocalRef(array);
|
||||
return env->NewStringUTF("");
|
||||
}
|
||||
const auto result = reinterpret_cast<jstring>(env->NewObject(c_string, m_new_string, array));
|
||||
jni_clear_exception(env);
|
||||
env->DeleteLocalRef(array);
|
||||
if (result == nullptr) {
|
||||
return env->NewStringUTF("");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
jbyteArray jni_new_bytes(JNIEnv *env, const char *str) {
|
||||
const auto length = str == nullptr ? 0 : static_cast<int>(strlen(str));
|
||||
const auto array = env->NewByteArray(length);
|
||||
if (jni_clear_exception(env) || array == nullptr) {
|
||||
return nullptr;
|
||||
}
|
||||
env->SetByteArrayRegion(array, 0, length, reinterpret_cast<const jbyte *>(str));
|
||||
if (jni_clear_exception(env)) {
|
||||
env->DeleteLocalRef(array);
|
||||
return nullptr;
|
||||
}
|
||||
return array;
|
||||
}
|
||||
|
||||
void jni_attach_thread(scoped_jni *jni) {
|
||||
JavaVM *vm = global_java_vm();
|
||||
if (vm->GetEnv(reinterpret_cast<void **>(&jni->env), JNI_VERSION_1_6) == JNI_OK) {
|
||||
if (global_vm->GetEnv(reinterpret_cast<void **>(&jni->env), JNI_VERSION_1_6) == JNI_OK) {
|
||||
jni->require_release = 0;
|
||||
return;
|
||||
}
|
||||
if (vm->AttachCurrentThread(&jni->env, nullptr) != JNI_OK) {
|
||||
if (global_vm->AttachCurrentThreadAsDaemon(&jni->env, nullptr) != JNI_OK) {
|
||||
abort();
|
||||
}
|
||||
if (detach_key_ready && pthread_setspecific(detach_key, jni->env) == 0) {
|
||||
jni->require_release = 0;
|
||||
return;
|
||||
}
|
||||
jni->require_release = 1;
|
||||
}
|
||||
|
||||
void jni_detach_thread(const scoped_jni *env) {
|
||||
JavaVM *vm = global_java_vm();
|
||||
if (env->require_release) {
|
||||
vm->DetachCurrentThread();
|
||||
global_vm->DetachCurrentThread();
|
||||
}
|
||||
}
|
||||
|
||||
void release_string(char **str) {
|
||||
free(*str);
|
||||
}
|
||||
void release_string(char **value) {
|
||||
free(*value);
|
||||
}
|
||||
|
||||
@@ -11,15 +11,17 @@ 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 jbyteArray jni_new_bytes(JNIEnv *env, const char *str);
|
||||
|
||||
extern int jni_catch_exception(JNIEnv *env);
|
||||
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 **str);
|
||||
extern void release_string(char **value);
|
||||
|
||||
extern bool jni_clear_exception(JNIEnv *env);
|
||||
|
||||
#define ATTACH_JNI() __attribute__((unused, cleanup(jni_detach_thread))) \
|
||||
scoped_jni _jni{}; \
|
||||
@@ -34,3 +36,4 @@ extern void release_string( char **str);
|
||||
#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)
|
||||
#define new_bytes(cstr) jni_new_bytes(env, cstr)
|
||||
|
||||
@@ -2,82 +2,77 @@ package com.follow.clash.core
|
||||
|
||||
import java.net.InetAddress
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.URL
|
||||
import java.net.URI
|
||||
|
||||
data object Core {
|
||||
object Core {
|
||||
private external fun startTun(
|
||||
fd: Int,
|
||||
cb: TunInterface,
|
||||
stack: String,
|
||||
address: String,
|
||||
dns: String,
|
||||
)
|
||||
): Boolean
|
||||
|
||||
external fun forceGC(
|
||||
)
|
||||
external fun forceGC()
|
||||
|
||||
external fun updateDNS(
|
||||
dns: String,
|
||||
)
|
||||
|
||||
private fun parseInetSocketAddress(address: String): InetSocketAddress {
|
||||
val url = URL("https://$address")
|
||||
|
||||
return InetSocketAddress(InetAddress.getByName(url.host), url.port)
|
||||
val uri = URI("tcp://$address")
|
||||
val host = requireNotNull(uri.host) { "Missing host in address: $address" }
|
||||
require(uri.port >= 0) { "Missing port in address: $address" }
|
||||
return InetSocketAddress(InetAddress.getByName(host), uri.port)
|
||||
}
|
||||
|
||||
fun startTun(
|
||||
fd: Int,
|
||||
protect: (Int) -> Boolean,
|
||||
resolverProcess: (protocol: Int, source: InetSocketAddress, target: InetSocketAddress, uid: Int) -> String,
|
||||
resolveUid: (protocol: Int, source: InetSocketAddress, target: InetSocketAddress) -> Int,
|
||||
resolvePackage: (uid: Int) -> String,
|
||||
stack: String,
|
||||
address: String,
|
||||
dns: String,
|
||||
) {
|
||||
startTun(
|
||||
): Boolean {
|
||||
return startTun(
|
||||
fd,
|
||||
object : TunInterface {
|
||||
override fun protect(fd: Int) {
|
||||
protect(fd)
|
||||
}
|
||||
override fun protect(fd: Int): Boolean = protect(fd)
|
||||
|
||||
override fun resolverProcess(
|
||||
override fun resolveUid(
|
||||
protocol: Int,
|
||||
source: String,
|
||||
target: String,
|
||||
uid: Int
|
||||
): String {
|
||||
return resolverProcess(
|
||||
): Int {
|
||||
return resolveUid(
|
||||
protocol,
|
||||
parseInetSocketAddress(source),
|
||||
parseInetSocketAddress(target),
|
||||
uid,
|
||||
)
|
||||
}
|
||||
|
||||
override fun resolvePackage(uid: Int): String = resolvePackage(uid)
|
||||
},
|
||||
stack,
|
||||
address,
|
||||
dns
|
||||
dns,
|
||||
)
|
||||
}
|
||||
|
||||
external fun suspended(
|
||||
suspended: Boolean,
|
||||
private external fun invokeMethod(
|
||||
data: String,
|
||||
cb: InvokeInterface,
|
||||
)
|
||||
|
||||
private external fun invokeAction(
|
||||
fun invokeMethod(
|
||||
data: String,
|
||||
cb: InvokeInterface
|
||||
)
|
||||
|
||||
fun invokeAction(
|
||||
data: String,
|
||||
cb: (result: String?) -> Unit
|
||||
cb: (result: ByteArray?) -> Unit,
|
||||
) {
|
||||
invokeAction(
|
||||
invokeMethod(
|
||||
data,
|
||||
object : InvokeInterface {
|
||||
override fun onResult(result: String?) {
|
||||
override fun onResult(result: ByteArray?) {
|
||||
cb(result)
|
||||
}
|
||||
},
|
||||
@@ -86,33 +81,33 @@ data object Core {
|
||||
|
||||
private external fun setEventListener(cb: InvokeInterface?)
|
||||
|
||||
fun callSetEventListener(
|
||||
cb: ((result: String?) -> Unit)?
|
||||
fun updateEventListener(
|
||||
callback: ((result: String?) -> Unit)?,
|
||||
) {
|
||||
when (cb != null) {
|
||||
true -> setEventListener(
|
||||
if (callback == null) {
|
||||
setEventListener(null)
|
||||
} else {
|
||||
setEventListener(
|
||||
object : InvokeInterface {
|
||||
override fun onResult(result: String?) {
|
||||
cb(result)
|
||||
override fun onResult(result: ByteArray?) {
|
||||
callback(result?.decodeToString())
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
false -> setEventListener(null)
|
||||
}
|
||||
}
|
||||
|
||||
fun quickSetup(
|
||||
initParamsString: String,
|
||||
setupParamsString: String,
|
||||
cb: (result: String?) -> Unit,
|
||||
callback: (result: String?) -> Unit,
|
||||
) {
|
||||
quickSetup(
|
||||
initParamsString,
|
||||
setupParamsString,
|
||||
object : InvokeInterface {
|
||||
override fun onResult(result: String?) {
|
||||
cb(result)
|
||||
override fun onResult(result: ByteArray?) {
|
||||
callback(result?.decodeToString())
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -121,7 +116,7 @@ data object Core {
|
||||
private external fun quickSetup(
|
||||
initParamsString: String,
|
||||
setupParamsString: String,
|
||||
cb: InvokeInterface
|
||||
cb: InvokeInterface,
|
||||
)
|
||||
|
||||
external fun stopTun()
|
||||
@@ -133,4 +128,4 @@ data object Core {
|
||||
init {
|
||||
System.loadLibrary("core")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,5 +4,5 @@ import androidx.annotation.Keep
|
||||
|
||||
@Keep
|
||||
interface InvokeInterface {
|
||||
fun onResult(result: String?)
|
||||
fun onResult(result: ByteArray?)
|
||||
}
|
||||
@@ -4,6 +4,11 @@ import androidx.annotation.Keep
|
||||
|
||||
@Keep
|
||||
interface TunInterface {
|
||||
fun protect(fd: Int)
|
||||
fun resolverProcess(protocol: Int, source: String, target: String, uid: Int): String
|
||||
}
|
||||
/** Returns false when the socket could not be kept out of the tunnel. */
|
||||
fun protect(fd: Int): Boolean
|
||||
|
||||
/** Returns the uid owning the connection, or -1 when the system will not name it. */
|
||||
fun resolveUid(protocol: Int, source: String, target: String): Int
|
||||
|
||||
fun resolvePackage(uid: Int): String
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
org.gradle.jvmargs=-Xmx4G
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
# This builtInKotlin flag was added automatically by Flutter migrator
|
||||
android.builtInKotlin=false
|
||||
# This newDsl flag was added automatically by Flutter migrator
|
||||
android.newDsl=false
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
[versions]
|
||||
#agp = "8.10.1"
|
||||
firebaseBom = "34.2.0"
|
||||
minSdk = "23"
|
||||
targetSdk = "36"
|
||||
compileSdk = "36"
|
||||
ndkVersion = "28.0.13004108"
|
||||
coreKtx = "1.17.0"
|
||||
annotationJvm = "1.9.1"
|
||||
coreSplashscreen = "1.0.1"
|
||||
gson = "2.13.1"
|
||||
kotlin = "2.2.10"
|
||||
smaliDexlib2 = "3.0.9"
|
||||
firebaseCrashlyticsKtx = "20.0.1"
|
||||
firebaseCommonKtx = "22.0.0"
|
||||
firebaseBom = "34.19.0"
|
||||
minSdk = "24"
|
||||
targetSdk = "37"
|
||||
compileSdk = "37"
|
||||
ndkVersion = "28.2.13676358"
|
||||
coreKtx = "1.19.1"
|
||||
annotationJvm = "1.11.0"
|
||||
coreSplashscreen = "1.2.0"
|
||||
gson = "2.14.0"
|
||||
smaliDexlib2 = "3.0.10"
|
||||
junit = "4.13.2"
|
||||
kotlinxCoroutines = "1.11.0"
|
||||
|
||||
[libraries]
|
||||
build-kotlin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
|
||||
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" }
|
||||
@@ -23,6 +20,6 @@ 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" }
|
||||
junit = { module = "junit:junit", version.ref = "junit" }
|
||||
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
|
||||
smali-dexlib2 = { module = "com.android.tools.smali:smali-dexlib2", version.ref = "smaliDexlib2" }
|
||||
firebase-crashlytics-ktx = { group = "com.google.firebase", name = "firebase-crashlytics-ktx", version.ref = "firebaseCrashlyticsKtx" }
|
||||
firebase-common-ktx = { group = "com.google.firebase", name = "firebase-common-ktx", version.ref = "firebaseCommonKtx" }
|
||||
+1
-1
@@ -2,5 +2,5 @@ distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-all.zip
|
||||
|
||||
|
||||
@@ -2,34 +2,24 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("kotlin-parcelize")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.follow.clash.service"
|
||||
compileSdk = 36
|
||||
compileSdk = libs.versions.compileSdk.get().toInt()
|
||||
|
||||
defaultConfig {
|
||||
minSdk = libs.versions.minSdk.get().toInt()
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
aidl = true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
sourceSets {
|
||||
// Unit tests live under android/tests/ instead of each module's src/test.
|
||||
getByName("test").java.setSrcDirs(listOf("../tests/service"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,10 +29,11 @@ kotlin {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core"))
|
||||
implementation(project(":common"))
|
||||
implementation(libs.gson)
|
||||
implementation(libs.androidx.core)
|
||||
}
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
}
|
||||
|
||||
@@ -8,8 +8,7 @@
|
||||
android:name=".VpnService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse"
|
||||
android:permission="android.permission.BIND_VPN_SERVICE"
|
||||
android:process=":remote">
|
||||
android:permission="android.permission.BIND_VPN_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.net.VpnService" />
|
||||
</intent-filter>
|
||||
@@ -19,31 +18,23 @@
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".CommonService"
|
||||
android:name=".ProxyService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse"
|
||||
android:process=":remote">
|
||||
android:foregroundServiceType="specialUse">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="proxy" />
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".RemoteService"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:process=":remote" />
|
||||
|
||||
<provider
|
||||
android:name=".FilesProvider"
|
||||
android:authorities="${applicationId}.files"
|
||||
android:exported="true"
|
||||
android:grantUriPermissions="true"
|
||||
android:permission="android.permission.MANAGE_DOCUMENTS"
|
||||
android:process=":remote">
|
||||
android:permission="android.permission.MANAGE_DOCUMENTS">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
|
||||
</intent-filter>
|
||||
</provider>
|
||||
</application>
|
||||
</manifest>
|
||||
</manifest>
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
// IAckInterface.aidl
|
||||
package com.follow.clash.service;
|
||||
|
||||
import com.follow.clash.service.IAckInterface;
|
||||
|
||||
interface IAckInterface {
|
||||
oneway void onAck();
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user