Compare commits

...

8 Commits

Author SHA1 Message Date
chen08209 0d3b28eae7 Optimize core service 2026-08-09 17:45:04 +08:00
chen08209 b0d99a5cad Optimize Android TV launcher icon 2026-08-09 13:19:00 +08:00
chen08209 bd60dd40d8 Optimize back navigation 2026-08-09 13:19:00 +08:00
chen08209 497cf9b391 Optimize more details 2026-08-09 13:18:18 +08:00
chen08209 30e7561521 Fix some issues 2026-08-09 13:18:18 +08:00
chen08209 b3c8308b5f Optimize app layout 2026-08-09 13:18:18 +08:00
chen08209 579aaac4ed Optimize focus control 2026-08-09 13:18:18 +08:00
chen08209 7c559944c0 Adjust android process 2026-08-08 08:13:19 +08:00
374 changed files with 27801 additions and 10189 deletions
+250 -23
View File
@@ -7,23 +7,155 @@ The Go proxy core in `core/` operates in two modes.
Android lib mode:
- Go core is compiled as a C shared library, `libclash.so`, through `go build -buildmode=c-shared` with CGO.
- Flutter calls it via FFI through the `service` plugin.
- Dart-side implementation: `lib/core/lib.dart` (`CoreLib`).
- The Android `:core` module owns JNI access to the in-process library. Flutter crosses the `${packageName}/service`
MethodChannel through `lib/plugins/service.dart` and Android's `ServicePlugin` rather than talking to JNI directly.
- `lib/core/lib.dart` (`CoreLib`) implements the shared Core interface, gates method calls on its connection completer,
initializes and synchronizes Android shared state, and closes the native service path exactly once.
- Because Core is in the application process on Android, application RSS already includes Core memory.
Desktop core mode:
- Go core runs as a separate process with `CGO_ENABLED=0`.
- Flutter communicates via JSON over socket, using a Unix socket on macOS/Linux and TCP on Windows.
- Dart-side implementation: `lib/core/service.dart` (`CoreService`).
- `rust_api` provides the native local-IPC primitives: a Unix domain socket on macOS/Linux and a named pipe on Windows.
Dart now owns the transport state, RPC correlation, process ownership, and lifecycle convergence above those primitives.
- `lib/core/service.dart` (`CoreService`) is the composition root. It wires the IPC transport, launcher selection,
lifecycle controller, RPC client, and crash-event bridge; it is no longer the whole desktop implementation by itself.
- `lib/core/desktop/transport.dart` converts native IPC frames into ready, connected, disconnected, failed, and data
events. A replaceable binding keeps RPC subscriptions stable when a failed or stale transport must be rebuilt.
- `lib/core/desktop/rpc_client.dart` owns request IDs and pending completers, waits up to 10 seconds for a connection,
applies a three-minute default method timeout, unwraps `CoreMethodResponse`, and fails all pending calls when transport
disconnects or closes.
- `lib/core/desktop/lifecycle.dart` serializes process intents and owns the authoritative desktop state machine.
- `lib/core/desktop/launcher.dart` abstracts direct child-process and Windows Helper ownership through idempotent process
leases. `lib/core/desktop/helper_client.dart` is the typed loopback HTTP client for the privileged Helper.
`lib/core/controller.dart` (`CoreController`) selects the implementation based on platform. `lib/core/interface.dart` defines the shared `CoreHandlerInterface`.
Key Go core files:
- `core/hub.go`: handler functions.
- `core/action.go`: dispatch.
- `core/method.go`: MethodChannel-style method-call dispatch and response envelopes.
- `core/message.go`: non-blocking priority/bulk event queues and bounded message batching.
- `core/lib.go`: CGO exports.
- `core/server.go`: socket server.
- `core/server.go`: desktop socket/named-pipe client and framed message forwarding.
## Lifecycle Ownership And Convergence
### Shared Flutter Layer
`CoreController.start()`, `restart()`, `stop()`, and `close()` are the only shared lifecycle facade. `close()` is terminal;
callers must not try to reuse a closed platform implementation.
`CoreAction` in `lib/providers/actions/core.dart` owns the user-facing Core status and setup sequence:
- `startCore()` publishes `connecting`, starts the platform Core, publishes `connected`, then initializes Core state. A
startup error publishes `disconnected` and displays the error.
- `restartCore()` coalesces overlapping callers behind one worker. `_requestedRestartRevision` records newer requests,
while `_latestExplicitStart` retains the newest requested post-restart running intent. After the lifecycle restart and
`initCore()`, the worker reapplies profile/running state until it has consumed the latest revision.
- The provider is an orchestration and presentation layer, not a process owner. Platform lifecycle code remains responsible
for determining whether a Core process/service is actually running.
Application exit is centralized in `SystemAction` and `SystemExitCoordinator`:
1. Optionally save config and clean up DNS, system proxy, and tray resources in parallel.
2. Close the desktop window.
3. Call terminal `CoreController.close()`.
4. Exit the application exactly once.
The coordinator is idempotent, continues later cleanup steps after an earlier error, preserves the first error for the
caller, and uses a three-second watchdog as an emergency application-exit path. `Application.dispose()` and
`CoreManager.onCrash()` do not independently destroy Core; this avoids competing shutdown owners.
### Desktop Lifecycle
`DesktopCoreLifecycle` is a latest-desired-intent reconciler, not a queue that blindly executes every request:
- Public intents receive monotonically increasing revisions and target running, restarted, stopped, or closed.
- Observable phases are `idle`, `starting`, `running`, `stopping`, `failed`, and `closed`.
- A completed command reports `applied`, `coalesced`, or `superseded`, allowing callers and tests to distinguish a command
that won from one satisfied or replaced by a newer compatible intent.
- Startup opens or replaces the IPC transport, resolves a launcher, generates a 128-bit lowercase hexadecimal session ID,
launches Core, and waits for the matching connection. Windows additionally verifies that the named-pipe peer PID equals
the process PID returned by the Helper lease.
- Each running session retains its process owner, lease, PID, session ID, and transport connection generation. Stop waits
for both process-exit confirmation and the matching disconnect generation; a missing disconnect replaces the transport
before later starts.
- An unconfirmed process exit is retained as an unconfirmed lease. New start/restart intents fail until ownership can be
cleaned up, preventing two Core instances from being treated as the active session. Terminal close may continue on a
best-effort basis because the application is exiting.
- An unexpected disconnect or transport failure while running is converted to `DesktopCoreFailure`, the owned process is
cleaned up, and `CoreService` emits a Core crash event for the normal UI recovery path.
Direct launch is used on macOS/Linux and as the Windows fallback when the privileged Helper is not ready. When the Helper
is ready on Windows, the Helper owns the Core child and Dart owns it through a session-scoped lease.
### Android Service Lifecycle
Android deliberately keeps Flutter requests optimistic and the native layer authoritative:
- `ServicePlugin.start()` and `stop()` acknowledge immediately after submitting intent. They do not wait for service
creation, VPN permission, binding, TUN establishment, or teardown.
- `ServiceState` owns the latest `RunRequest`, shared configuration, run time, and `STOPPED`/`STARTING`/`STARTED`/`STOPPING`
state. Identity checks discard obsolete work. `startPreparationLock` serializes permission/setup preparation and
`transitionLock` serializes actual service transitions.
- `ServiceController` owns exactly one `ManagedServiceBinding`, selects `VpnService` or `ProxyService` from `VpnOptions`,
binds with a five-second connection timeout, invokes `ManagedService.start()`/`stop()` off the main thread, and clears
binding/run-time state on failure or disconnection.
- Generic service creation/destruction is lifecycle evidence, not user intent. New commands must flow through
`ServiceState.requestStart()`/`requestStop()` or the explicit system-action handlers instead of inferring intent from a
callback.
Quick Settings, notification, revoke, and Always-on VPN paths converge on the same owner:
- With a Flutter engine attached, `ServiceState.handleStartAction()`/`handleStopAction()` forward through `TilePlugin` to
`TileManager`, which updates normal Flutter setup state. Without Flutter, native code restores `SharedState` from
preferences, runs `quickSetup`, checks VPN permission, and submits the native request directly.
- Android may create an Always-on `VpnService` through `onStartCommand()` without FlClash's bound-service path. The service
sends the explicit, permission-protected `VPN_START_REQUESTED` broadcast to `ServiceBroadcastReceiver`, which routes it to
`ServiceState.handleStartAction()` so Core/configuration and the normal binding are restored before TUN is treated as
ready.
- `VpnService.onRevoke()` stops TUN/modules first, then sends `VPN_REVOKED`; the receiver only requests a stop when
`ServiceController` still owns an active VPN binding.
- `ServiceBroadcastReceiver` uses `goAsync()` and an atomic one-shot completion. Normal completion or a nine-second
watchdog calls `PendingResult.finish()` exactly once; the watchdog releases Android's broadcast lease and does not
cancel or redefine the underlying lifecycle intent.
## Core Protocol And Event Delivery
The shared protocol uses `CoreMethodCall(id, method, arguments)` and `CoreMethodResponse(id, result, error)` in both
directions. The envelope is the only JSON serialization layer: keep arguments, results, and event data as structured JSON
values rather than embedding pre-encoded JSON strings. Plain domain strings, such as country codes or provider contents,
remain strings.
Go event delivery is intentionally non-blocking:
- State-bearing events such as delay, loaded-provider, and geo-update use a 256-entry priority queue. Desktop process
crashes are generated locally by `CoreService` from lifecycle failures rather than sent through the Go queue.
- High-volume log and request/connection events use a separate 256-entry bulk queue, so bulk floods cannot evict state.
- A full queue evicts only its own oldest event and retries the newest event; Core work never blocks on event delivery.
- The batcher flushes at 32 messages or every 16 milliseconds. Priority events are preferred, but one bulk opportunity is
guaranteed after eight priority messages to prevent starvation.
Desktop RPC accepts both a single event object and batched event lists. Android and desktop listener dispatch isolate
listener exceptions so one faulty observer does not prevent the remaining events/listeners from running.
## User-Facing Core And Delay Feedback
`CoreStatusButton` in `lib/views/dashboard/widgets/core_status_button.dart` is the desktop dashboard's status/restart
surface. It is shown only outside dashboard edit mode and only when `coreLib == null`:
- Provider state remains authoritative. The widget keeps a separate display-only status so a fast
`connecting -> connected` transition still shows at least 600 milliseconds of progress instead of flashing.
- The hold arms only after an observed transition to `connecting`; mounting while already connecting does not invent a new
delay. A real `disconnected` transition cancels the hold immediately so failure is never hidden, while a long-running
connecting state remains visible after the timer expires.
- Taps during the display hold or while the provider is genuinely connecting are inert. Connected/disconnected taps show
the appropriate confirmation and delegate restart to `CoreAction`; the widget never starts Core directly.
Proxy delay testing follows the same failure-safe UI rule. `proxyDelayTest()` records an in-progress zero delay, writes the
real result on success, and logs plus records `-1` on exceptions. `DelayTestButton` reverses its animation in `finally`, so
an RPC failure cannot leave the control permanently spinning.
## State Management
@@ -71,17 +203,21 @@ Each manager in `lib/manager/` handles a specific platform concern. Desktop-only
`lib/core/controller.dart` (`CoreController`) is a singleton facade over `CoreHandlerInterface`. Public methods delegate to the platform-specific interface, either Android FFI or desktop socket. It has an `@visibleForTesting` constructor and `resetInstance()` for test injection.
Business logic lives in Riverpod notifier classes in `lib/providers/action.dart`:
`lib/providers/action.dart` is the public library entry point for action
providers. The Riverpod notifier implementations are split by responsibility
under `lib/providers/actions/` and joined to the entry point with `part`
directives, so consumers continue to import the same public API:
- `CommonAction`: update check and common UI operations.
- `SetupAction`: config setup and TUN management.
- `BackupAction`: backup/restore with WebDAV sync.
- `CoreAction`: core lifecycle, init, connect, restart, shutdown.
- `SystemAction`: system integration, tray, exit, brightness.
- `CoreAction`: core lifecycle, initialization, coalesced restart, and post-restart profile/running-state application.
- `SystemAction`: system integration, tray, coordinated resource cleanup, terminal Core close, exit, and brightness.
- `StoreAction`: profile storage operations.
- `ThemeAction`: theme state updates.
- `ProxiesAction`: group management and proxy selection.
- `ProfilesAction`: profile CRUD, auto-update, import.
- `GeoResourceAction`: geo resource updates and URL configuration.
## Platform Managers
@@ -110,10 +246,10 @@ Shared:
`setup.dart` is the release build orchestrator:
1. On Windows, pre-builds Go core via `dart run build_tool windows` and reads `core_sha256.json`.
2. Writes `env.json` (`APP_ENV`).
3. Passes SHA256 as `--dart-define=CORE_SHA256=$val`, embedded at compile time for Windows.
4. Activates `flutter_distributor` for packaging.
1. Writes `env.json` (`APP_ENV`).
2. Activates `flutter_distributor` for packaging.
3. Relies on the platform build hook to build the required Core artifacts before
the native application is linked.
Go core building is handled by `build_tool`, a standalone Dart CLI in `plugins/setup/buildkit/build_tool/`.
@@ -121,25 +257,88 @@ Platform build hooks inside `flutter build` trigger `build_tool` automatically:
- macOS: podspec script phase, `build_pod.sh`, `build_tool macos`.
- Linux: CMake include, `buildkit/cmake/buildkit.cmake`, `build_tool linux`.
- Windows: CMake include, `buildkit/cmake/buildkit.cmake`, `build_tool windows`. Debug passes `--dev` via `CMAKE_BUILD_TYPE`.
- Windows: CMake include, `buildkit/cmake/buildkit.cmake`, `build_tool windows`. CMake forwards the active configuration through `BUILDKIT_CONFIGURATION`.
- Android: Gradle include, `buildkit/gradle/plugin.gradle`, `build_tool android`.
Windows helper auth:
### Setup Build Harness Plugin
- Release: Core SHA256 is embedded in both the Flutter app and the Rust helper. The app pings the helper and verifies the token matches.
- Debug: The Rust helper skips token verification when built in debug mode, so `flutter run` works without the SHA256 flow.
`plugins/setup/` is a build-time Flutter plugin, not a runtime Dart or FFI API. Its plugin shape exists so Flutter's native
build graphs can run the Go/Rust build harness before platform consumers need the generated artifacts. Application code
must not import or call it.
`plugins/setup/` is an FFI plugin that exists only as a build harness. It carries no Dart API, only platform build hooks that trigger Go compilation. Windows builds also compile a Rust helper in `services/helper/` through `RustBuilder`.
Responsibilities are deliberately split:
Build configuration defaults live in `build_tool/lib/src/options.dart` and can be overridden via `build_config.yaml`.
- CocoaPods, Gradle, and CMake hooks schedule a lightweight check on every native build. They do not decide which Go or
Rust files are stale.
- `buildkit/build_tool/` owns target resolution, input fingerprinting, compilation, output copying, and cache validation.
- `core/` and `services/helper/` remain source owners; `libclash/` and Android `jniLibs`/header directories are generated
output locations.
- `setup.dart` remains the release/package orchestrator and does not pre-build
platform artifacts or use `dart-define` for Core integrity data. The Windows
build tool writes the runtime `manifest.json` beside the Core output, and the
Windows bundle copies it beside the application executable.
Platform outputs remain explicit:
- Android builds the Go core as `c-shared`, then copies `libclash.so` and generated headers into the `:core` Android module.
- macOS and Linux build a standalone `FlClashCore` process used by the desktop socket integration.
- Windows builds `FlClashCore.exe`, the Rust `FlClashHelperService.exe` privileged helper, and a
`manifest.json` containing only `coreSha256`.
The hooks follow rust_api/Cargokit's phony-output scheduling pattern, but setup uses its own cache because it builds both a
Go core and, on Windows, a separate Rust helper. Per-target records live under `.dart_tool/setup_build_cache/v1/`:
- Go fingerprints cover the target-specific `go list -deps` inputs inside `core/` and `Clash.Meta`, module files, effective
build configuration, build-tool sources, target flags, Go environment/toolchain, and Android NDK compiler details.
- Windows helper fingerprints cover its Rust sources and manifests, Cargo/Rust
toolchains and flags, and the expected Core SHA256.
- A cache hit requires the fingerprint and every recorded output's path, size, and modification state to match. It exits
silently without Go/Cargo compilation, output copying, or Windows `taskkill`.
- Cache records are written only after a successful build and protected by per-target process/file locks. Missing outputs,
changed inputs, cache-schema changes, or `--force` rebuild only the affected target.
- `flutter clean` removes `.dart_tool`, so the next native build performs one full core rebuild. Manual builds can bypass
the cache with `make core-<platform> FORCE=1`.
This differs from `rust_api`: rust_api is a runtime Flutter Rust Bridge integration whose Cargokit hooks produce its native
FFI library, while setup is only the build and packaging bridge for FlClash's external core artifacts.
Windows helper integrity/version check:
- The build tool constructs the Core first, calculates its SHA256, and always
builds the Rust Helper with release hardening and that expected hash.
- Flutter reads the Core SHA256 from the bundled `manifest.json` and sends it with `/ping`. Debug, Profile, and Release
builds use the same Helper protocol and may use TUN through the same flow.
- `/ping` is loopback-only and requires no request token. The Helper compares the requested SHA256 with its embedded value
and checks that the fixed `FlClashCore.exe` beside it exists; `/start` performs the actual Core SHA256 verification before
every launch. The response includes the running Helper path and protocol header; Dart checks both against the current
installation. The launcher selects the Helper only when `/ping` reports ready; any other readiness (missing manifest,
unavailable Helper, or a Helper built for a different Core) falls back to the direct Core without requesting elevation.
If `/start` reports a pre-spawn failure — `coreVerificationFailed` (the on-disk Core no longer matches the SHA the
Helper and manifest agree on) or `processLaunchFailed` (the Core process could not be spawned) — the launcher degrades
to the direct Core rather than failing the launch. `/start` releases the previously managed Core before it verifies,
so the Helper owns no Core when either code is reported and the direct retry cannot race a Helper-managed Core.
A mismatched Helper is reinstalled through the explicit TUN authorization flow, not at startup.
- TUN is not a required run condition. A direct Core runs unelevated and cannot bring up TUN, so any degrade to the
direct Core — an unready Helper at resolve time, or a pre-spawn `/start` failure — silently drops TUN and keeps the
Core running. Degrading is preferred over failing the launch: an unverified Core carries no privilege the direct
launch path did not already have. `manifestMissing` is the one readiness that is surfaced to the user, because it
means the installation itself is incomplete.
- Flutter creates a 128-bit lowercase-hex session ID and uses it as the random named-pipe suffix. `/start` receives only
that address and session ID, validates the fixed `FlClashCore_<session>` namespace, starts the fixed Core beside the
Helper, and returns the same session ID plus the spawned PID. Flutter verifies both the session and named-pipe peer PID.
- `/stop` requires the same session ID. A missing process returns `notRunning`; a different owner returns
`sessionMismatch` without terminating that process. Session IDs are ownership tokens for lifecycle safety, not a claim
that the loopback HTTP endpoints are authenticated.
Build configuration defaults live in `build_tool/lib/src/options.dart` and can be overridden via a root `build_config.yaml`.
Architecture detection is automatic. The `--description` flag passed to `flutter_distributor` adds arch suffixes to artifact names, such as `FlClash-0.8.93-macos-arm64.dmg`.
## Local Plugins
- `setup`: build harness FFI plugin.
- `setup`: build-time harness for Go core artifacts and the Windows Rust helper; no runtime Dart API.
- `proxy`: system proxy configuration.
- `rust_api`: Flutter Rust Bridge FFI plugin.
- `rust_api`: runtime Flutter Rust Bridge FFI plugin built through Cargokit.
- `tray_manager`: system tray fork/customization.
- `wifi_ssid`: Wi-Fi SSID detection.
- `window_ext`: window extensions.
@@ -150,7 +349,35 @@ Architecture detection is automatic. The `--description` flag passed to `flutter
`services/helper/` is a Windows-only privileged helper for starting the core as admin and managing TUN. It is built with:
```bash
cargo build --release --features windows-service
make core-windows
```
It uses token-based auth with the Flutter app.
The build tool always compiles the Helper in Rust release mode after calculating
the SHA256 of the Core produced for the active Flutter configuration.
The helper owns its Windows Service Control Manager lifecycle through two elevated commands:
- `FlClashHelperService.exe install` stops and removes any stale registration, creates the auto-start service for the
current executable path, starts it, and waits for the running state.
- `FlClashHelperService.exe uninstall` stops the service, waits for shutdown, removes its registration, and is also used
by the Windows package uninstaller.
The Dart layer only launches the helper's `install` command through `ShellExecuteW`; it does not compose `sc.exe`,
`taskkill`, or `cmd.exe` command lines.
In every Flutter build mode `/start` opens the fixed Core executable beside the Helper without write/delete sharing,
validates it against the SHA256 embedded only in the Helper, and keeps that handle open through process creation.
`/ping` only compares the requested `coreSha256` with the Helper's embedded value and checks the fixed Core path exists;
it never hashes the Core. Protocol version 6 uses 32-character lowercase-hex session ownership:
- `GET /ping?coreSha256=...` returns the current Helper executable path with `x-flclash-helper-protocol` when the
requested SHA matches.
- `POST /start` rejects unknown JSON fields, validates `{address, sessionId}`, then releases any previously managed Core
before verifying the Core — so every outcome, including a rejected one, leaves the Helper owning no Core — and returns
`{sessionId, pid}`.
- `POST /stop` validates `{sessionId}` and only stops the matching managed Core. A session mismatch is HTTP 409.
- `GET /logs` exposes the bounded recent Helper/Core stderr buffer with `no-store` caching.
All endpoints bind only to `127.0.0.1:47890` and do not use request-token authentication. Lifecycle safety comes from the
fixed executable/hash, strict pipe namespace, session-scoped stop contract, and Dart-side peer-PID verification. When the
Helper service itself shuts down, it unconditionally stops the Core process it owns.
+66 -10
View File
@@ -33,19 +33,14 @@ make core-macos ARCH=arm64
make core-android TARGET_PLATFORM=android-arm64
```
Core builds use setup's input fingerprint cache. Pass `FORCE=1` to bypass it,
for example `make core-macos ARCH=arm64 FORCE=1`.
The Makefile wraps `plugins/setup/buildkit/run_build_tool.sh`; prefer the `make` entry points unless debugging the build tool itself.
## Flutter Development
The project is pinned with FVM.
```bash
fvm flutter pub get
fvm flutter run
fvm flutter test
```
Plain Flutter also works when the global SDK matches project constraints:
Use the default Flutter SDK directly:
```bash
flutter pub get
@@ -83,6 +78,7 @@ Tests use `package:test/test.dart` for pure Dart logic and `flutter_test` for pr
```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/
@@ -93,9 +89,62 @@ flutter test plugins/proxy/test/proxy_test.dart
Root `flutter test` only discovers the root package's `test/` directory by default. Include bundled plugin Dart tests by passing paths explicitly, or run `flutter test` from that plugin package directory. Native plugin tests under platform folders are not run by `flutter test`.
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; use JDK 17 in this checkout:
```bash
cd android
JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home ./gradlew :service:compileDebugKotlin
JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home ./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.
## Verify
CI runs these in order:
The tag-triggered release workflow runs these root-package checks in order:
```bash
flutter pub get
@@ -104,3 +153,10 @@ flutter test --reporter expanded
```
Run `flutter analyze` locally before committing when practical.
The workflow runs only for `v*` tag pushes; pull requests do not trigger it.
Root analysis excludes `plugins/**`, and root tests do not discover nested
plugin packages, so CI also validates local Flutter packages, the setup build
tool, the Go wrapper, and Rust components from their own package directories. A
separate Windows runner compiles and tests the helper's `windows-service`
feature before release builds can start.
+2 -2
View File
@@ -4,8 +4,8 @@ FlClash is a multi-platform proxy client based on ClashMeta (mihomo), built with
## Version Notes
- `.fvmrc` pins Flutter 3.35.7 for local development.
- CI uses Flutter 3.41.9. These may diverge; trust CI as the source of truth for release builds.
- Release CI pins Flutter 3.44.4. Local SDK may diverge, so trust the CI
version as the source of truth for release builds.
- Dart SDK constraint: `>=3.8.0 <4.0.0`.
## Build Dependencies
+33
View File
@@ -21,8 +21,37 @@ Generated directories are excluded from analysis:
- `lib/**/generated/**`
- `plugins/**`
## Core API Safety
- Do not expose direct filesystem deletion APIs through Core or helper IPC; use
a scope-specific cleanup API instead.
- 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.
- Keep high-volume log/request events separate from state-bearing events in `core/message.go`; bulk backpressure must not
evict delay, loaded-provider, or geo-update state.
## Lifecycle Rules
- 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.
- 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.
## 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;
verify cross-language protocol behavior through shared Dart contract tests under `test/core/` and native platform build
checks.
Use `CoreController.test(mock)` to inject a mocked `CoreHandlerInterface`. Call `CoreController.resetInstance()` in `tearDown` to clean up the singleton between tests.
Register fallback values for freezed params used with `any()` matchers.
@@ -35,10 +64,14 @@ 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.
## Generated Code
Do not manually edit generated files under:
- `lib/l10n/l10n.dart`
- `lib/models/generated/`
- `lib/providers/generated/`
- `lib/database/generated/`
+3 -2
View File
@@ -6,8 +6,9 @@ Repo-scoped Codex skills live under `.agents/skills/*/SKILL.md`. Codex can disco
- `localization`: hardcoded UI text scans, ARB updates, locale generation, and localization verification.
- `provider-tests`: Riverpod provider, notifier, and state-management tests.
- `ui-work`: Flutter UI, widgets, Material You styling, navigation surfaces, and user-facing interactions.
- `core-platform`: core integration, platform managers, Go core communication, desktop/mobile behavior, and Windows helper flow.
- `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.
## Authoring Notes
+53 -13
View File
@@ -1,26 +1,45 @@
---
name: core-platform
description: Use when changing FlClash core integration, platform managers, Go core communication, desktop/mobile platform behavior, or Windows helper flow.
description: Use when changing FlClash Core integration, lifecycle/process ownership, Go event delivery, Android services, desktop IPC, platform managers, VPN/TUN, or Windows Helper flow.
---
# Core And Platform
## When To Use
Use this for changes touching `lib/core/`, `lib/manager/`, `core/`, `services/helper/`, build hooks, system proxy, tray, VPN, TUN, or platform-specific desktop/mobile behavior.
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.
## Workflow
1. Identify which boundary owns the behavior:
- Android lib mode: `lib/core/lib.dart`.
- Desktop process/socket mode: `lib/core/service.dart`.
- Shared facade: `lib/core/controller.dart` and `lib/core/interface.dart`.
- Platform lifecycle: `lib/manager/`.
2. Route feature code through `CoreController` and `CoreHandlerInterface`; avoid direct calls to platform implementations outside their boundary.
3. Keep desktop and mobile paths explicit.
4. For action-layer behavior, inspect `lib/providers/action.dart` and relevant generated providers.
5. Add or update shared Dart tests for logic that can be isolated.
6. Manually verify native behavior when automated coverage is not practical.
1. Identify the authoritative owner before changing behavior:
- 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 Helper HTTP contract:
`lib/core/desktop/helper_client.dart` and `services/helper/`.
- Flutter orchestration: `lib/providers/actions/core.dart` and `system.dart`; UI/event observation: `lib/manager/`.
2. Trace every entry path into that owner, including UI/provider calls, Quick Settings, notification actions, Always-on VPN,
revoke callbacks, application exit, and crash/disconnect recovery. Lifecycle callbacks are not implicit user intent.
3. Preserve latest-intent semantics:
- Desktop revisions converge to running/restarted/stopped/closed and report applied/coalesced/superseded outcomes.
- Android Flutter calls stay optimistic; `ServiceState` identity-checks the latest native `RunRequest`.
4. Route feature calls through `CoreController` and `CoreHandlerInterface`. Do not bypass desktop process leases or create a
second Android service binding owner.
5. Keep JSON envelopes and event shapes identical across Dart, Go, JNI, and desktop IPC. If event traffic changes, preserve
the separate priority and bulk queues in `core/message.go`.
6. Keep shutdown single-owned and terminal. `SystemExitCoordinator` sequences resource cleanup, window close, Core close,
and process exit; widget/manager disposal must not race it.
7. Add or update focused tests at the narrowest layer, then run the matching commands from `.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.
- Windows Helper: Cargo format/tests; run the `windows-service` feature on Windows.
8. Explicitly state host gaps. Always-on VPN, VPN permission, system revoke, named-pipe peer identity, and Windows Service
Control Manager behavior need their real platform even when portable tests pass.
## Reference Files
@@ -28,6 +47,27 @@ Read `.agents/architecture.md` for the current core modes, manager stack, build
## Pitfalls
- Debug Windows helper auth differs from release token verification.
- Keep the Windows Helper protocol and Core SHA256 validation identical across
Flutter build modes; the Helper owns executable integrity checks.
- Protocol version 6 uses a 32-character lowercase-hex session ID. `/start` must return the submitted session and PID;
`/stop` must never terminate a different session; Dart must verify the connected named-pipe peer PID.
- `/start` must release the previously managed Core before it verifies, so no `/start` outcome leaves a Helper-managed
Core behind for the caller's direct-launch fallback to race.
- The Helper owns a managed Core until its exit is confirmed. A `200` from `/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 will race a Core the Helper still owns.
- TUN is not a required run condition. Degrading to the unelevated direct Core — and silently losing TUN — is the
expected outcome whenever the Helper path fails; do not fail the launch instead.
- A desktop process lease with unconfirmed exit must remain owned until cleanup succeeds. Do not discard it and start a
replacement Core.
- `CoreController.close()` is terminal. Do not call it from a reusable manager lifecycle or recover by starting it again.
- `ServiceBroadcastReceiver.goAsync()` must finish once even on timeout; its watchdog releases the broadcast only and must
not become a service timeout.
- Do not interpret service creation/destruction as start/stop intent. Always-on startup is explicit through
`VPN_START_REQUESTED`; revoke is explicit through `VPN_REVOKED`.
- Keep log/request floods from evicting state-bearing Core events. Each queue may evict only its own oldest item.
- Do not expose direct filesystem deletion APIs through Core or helper IPC; use
a scope-specific cleanup API instead.
- `plugins/setup/` is a build harness, not a Dart API plugin.
- Build hooks can trigger Go or Rust compilation indirectly through Flutter platform builds.
+17 -2
View File
@@ -1,6 +1,6 @@
---
name: ui-work
description: Use when changing FlClash Flutter UI, widgets, screens, Material You styling, navigation surfaces, or user-facing interactions.
description: Use when changing FlClash Flutter UI, widgets, screens, Material You styling, navigation surfaces, async feedback, or user-facing interactions.
---
# UI Work
@@ -18,7 +18,12 @@ Use this for user-facing Flutter UI changes in `lib/`, including widgets, screen
5. Prefer `const` constructors and final locals.
6. Localize user-facing text through ARB; use `localization` when text changes are non-trivial.
7. Add focused widget tests when behavior changes, especially for rendering states, taps, scrolling, and empty/error states.
8. Run targeted verification:
8. For asynchronous controls, define separately:
- authoritative provider/domain state;
- display-only state such as a minimum progress duration;
- tap policy while work or display holds are active;
- failure/disposal cleanup, normally in `finally` for animations and timers.
9. Run targeted verification:
```bash
flutter analyze
@@ -30,3 +35,13 @@ Use this for user-facing Flutter UI changes in `lib/`, including widgets, screen
- Do not introduce a new visual system for one screen.
- Do not manually edit generated localization or provider files.
- Avoid broad layout rewrites unless the requested change requires them.
- 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. Test the exception path, not only the successful tap.
## Current Interaction Examples
- `CoreStatusButton` watches `coreStatusProvider` but keeps its 600-millisecond connecting hold locally. Taps are ignored
during the hold or genuine connecting state; disconnected cancels the hold immediately.
- Proxy delay testing writes `0` while pending, the measured delay on success, and `-1` on failure. `DelayTestButton` resets
its animation in `finally`.
+14
View File
@@ -0,0 +1,14 @@
---
name: implementer
description: Use for scoped feature work, bug fixes, refactors, and focused test updates after the requirements are clear.
tools: Read, Grep, Glob, Bash, Edit, Write
model: sonnet
effort: high
---
Implement the requested change in the smallest appropriate scope. Follow the repository instructions in AGENTS.md and
the
linked .agents documentation, preserve existing lifecycle and serialization contracts, and keep unrelated working-tree
changes untouched. Trace the affected call path before editing, update focused tests when behavior changes, and report
the
files changed plus the verification actually run.
+11
View File
@@ -0,0 +1,11 @@
---
name: researcher
description: Use for fast read-only codebase exploration, call-path tracing, and locating relevant tests before implementation.
tools: Read, Grep, Glob
model: sonnet
effort: medium
---
Trace the actual call path instead of stopping at the first matching file. Identify the relevant symbols, lifecycle owner,
cross-platform boundary, existing tests, and focused verification commands. Do not modify files. Return concise,
evidence-based findings with file paths and line references when available.
+12
View File
@@ -0,0 +1,12 @@
---
name: reviewer
description: Use after code changes for correctness, regression, lifecycle, contract, and test review.
tools: Read, Grep, Glob, Bash
model: opus
effort: high
---
Review the current changes without editing files. Inspect the diff, trace the relevant call paths, and check platform
ownership, serialization or IPC contracts, error handling, and test coverage. Run focused read-only checks when useful.
Report actionable findings first, ordered by severity, with file paths and line references; state explicitly when no issue is
found and note any validation or platform coverage gaps.
+12
View File
@@ -0,0 +1,12 @@
name = "implementer"
description = "Use for scoped feature work, bug fixes, refactors, and focused test updates after the requirements are clear."
model = "gpt-5.6-sol"
model_reasoning_effort = "high"
sandbox_mode = "workspace-write"
developer_instructions = """
Implement the requested change in the smallest appropriate scope. Follow the repository
instructions in AGENTS.md and the linked .agents documentation. Preserve existing
lifecycle and serialization contracts, and keep unrelated working-tree changes
untouched. Trace the affected call path before editing, update focused tests when
behavior changes, and report the files changed plus the verification actually run.
"""
+11
View File
@@ -0,0 +1,11 @@
name = "researcher"
description = "Use for fast read-only codebase exploration, call-path tracing, and locating relevant tests before implementation."
model = "gpt-5.6-luna"
model_reasoning_effort = "high"
sandbox_mode = "read-only"
developer_instructions = """
Trace the actual call path instead of stopping at the first matching file. Identify
the relevant symbols, lifecycle owner, cross-platform boundary, existing tests, and
focused verification commands. Do not modify files. Return concise, evidence-based
findings with file paths and line references when available.
"""
+13
View File
@@ -0,0 +1,13 @@
name = "reviewer"
description = "Use after code changes for correctness, regression, lifecycle, contract, and test review."
model = "gpt-5.6-luna"
model_reasoning_effort = "max"
sandbox_mode = "read-only"
developer_instructions = """
Review the current changes without editing files. Inspect the diff, trace the relevant
call paths, and check platform ownership, serialization or IPC contracts, error
handling, and test coverage. Run focused read-only checks when useful. Report
actionable findings first, ordered by severity, with file paths and line references;
state explicitly when no issue is found and note any validation or platform coverage
gaps.
"""
+5
View File
@@ -0,0 +1,5 @@
[agents]
enabled = true
max_concurrent_threads_per_session = 3
default_subagent_model = "gpt-5.6-luna"
default_subagent_reasoning_effort = "high"
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -euo pipefail
previous_tag="${1:-}"
output="${2:-release.md}"
: > "$output"
append_range() {
local range="$1"
git log --no-merges --pretty=format:'%B' "$range" |
awk '!/Update changelog/ && NF {print "- " $0 "\n"}' >> "$output"
}
current_tag=""
while IFS= read -r next_tag; do
if [[ -n "$current_tag" ]]; then
[[ "$current_tag" == "$previous_tag" ]] && break
append_range "$next_tag..$current_tag"
fi
current_tag="$next_tag"
done < <(git tag --merged HEAD --sort=-creatordate)
if [[ -n "$current_tag" && "$current_tag" != "$previous_tag" ]]; then
append_range "$current_tag"
fi
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
generator="$script_dir/generate_release_notes.sh"
temp_dir="$(mktemp -d)"
trap 'rm -rf "$temp_dir"' EXIT
repo="$temp_dir/repo"
git init --quiet --initial-branch=main "$repo"
git -C "$repo" config user.email "release-notes-test@example.com"
git -C "$repo" config user.name "Release notes test"
commit() {
local message="$1"
local date="$2"
GIT_AUTHOR_DATE="$date" GIT_COMMITTER_DATE="$date" \
git -C "$repo" commit --allow-empty --quiet --message "$message"
}
commit "Initial release" "2025-01-01T00:00:00Z"
git -C "$repo" tag v1.0.0
commit "First release change" "2025-01-02T00:00:00Z"
commit "Update changelog" "2025-01-03T00:00:00Z"
git -C "$repo" tag v1.1.0
git -C "$repo" branch feature
git -C "$repo" checkout --quiet feature
commit $'Feature branch change\n\nFeature detail' "2025-01-04T00:00:00Z"
git -C "$repo" checkout --quiet main
GIT_AUTHOR_DATE="2025-01-05T00:00:00Z" \
GIT_COMMITTER_DATE="2025-01-05T00:00:00Z" \
git -C "$repo" merge --no-ff --quiet --message "Merge feature" feature
git -C "$repo" tag v1.2.0
expected="$temp_dir/expected.md"
actual="$temp_dir/actual.md"
printf '%s\n' \
"- Feature branch change" \
"" \
"- Feature detail" \
"" \
"- First release change" \
"" > "$expected"
(
cd "$repo"
bash "$generator" v1.0.0 "$actual"
)
diff -u "$expected" "$actual"
printf '%s\n' "stale content" > "$actual"
(
cd "$repo"
bash "$generator" v1.2.0 "$actual"
)
[[ ! -s "$actual" ]]
(
cd "$repo"
bash "$generator" "" "$actual"
)
grep -q -- "- Initial release" "$actual"
+84 -26
View File
@@ -18,6 +18,10 @@ jobs:
with:
submodules: recursive
- name: Test release notes generation
shell: bash
run: bash .github/scripts/generate_release_notes_test.sh
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
@@ -34,8 +38,73 @@ jobs:
- name: Run tests
run: flutter test --reporter expanded
- name: Validate local Flutter packages
shell: bash
run: |
for package in plugins/proxy plugins/wifi_ssid plugins/window_ext plugins/setup; do
(
cd "$package"
flutter pub get
flutter analyze --no-fatal-infos
)
done
- name: Run local plugin tests
shell: bash
run: |
(
cd plugins/proxy
flutter test --reporter expanded
)
(
cd plugins/wifi_ssid
flutter test --reporter expanded
)
- name: Validate setup build tool
working-directory: plugins/setup/buildkit/build_tool
run: |
dart pub get
dart analyze
dart test
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.26.4'
cache-dependency-path: core/go.sum
- name: Validate Go core wrapper
working-directory: core
env:
CGO_ENABLED: 0
run: |
go test .
go vet .
- name: Validate Rust components
run: |
cargo fmt --manifest-path services/helper/Cargo.toml -- --check
cargo test --manifest-path services/helper/Cargo.toml
cargo fmt --manifest-path plugins/rust_api/rust/Cargo.toml -- --check
cargo test --manifest-path plugins/rust_api/rust/Cargo.toml
windows-helper-test:
name: Windows helper test
runs-on: windows-2022
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Check format
run: cargo fmt --manifest-path services/helper/Cargo.toml -- --check
- name: Run Windows service tests
run: cargo test --manifest-path services/helper/Cargo.toml --features windows-service
build:
needs: [ test ]
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
needs: [ test, windows-helper-test ]
runs-on: ${{ matrix.os }}
strategy:
matrix:
@@ -149,18 +218,19 @@ jobs:
- name: Generate
run: |
last_ver=$(grep -m1 '^## ' CHANGELOG.md 2>/dev/null | sed 's/^## //')
tags=($(git tag --merged HEAD --sort=-creatordate))
temp="NEW_CHANGELOG.md" > "$temp"
mapfile -t tags < <(git tag --merged HEAD --sort=-creatordate)
temp="NEW_CHANGELOG.md"
: > "$temp"
for i in "${!tags[@]}"; do
curr="${tags[i]}"
[[ "$curr" == "$last_ver" ]] && break
prev="${tags[i+1]}"
range="${prev:+$prev..}$curr"
echo -e "## $curr\n" >> "$temp"
git log --no-merges --pretty=format:"%B" "$range" | \
awk '!/Update changelog/ && NF {print "- " $0 "\n"}' >> "$temp"
@@ -215,25 +285,13 @@ jobs:
merge-multiple: true
- name: Generate release.md
shell: bash
run: |
tags=($(git tag --merged HEAD --sort=-creatordate))
preTag=$(curl -s "https://api.github.com/repos/chen08209/FlClash/releases/latest" | \
sed -nE 's/.*"tag_name": "([^"]+)".*/\1/p')
[ -z "$preTag" ] && preTag=""
out="release.md" > "$out"
for i in "${!tags[@]}"; do
curr="${tags[i]}"
[[ "$curr" == "$preTag" ]] && break
prev="${tags[i+1]}"
range="${prev:+$prev..}$curr"
git log --no-merges --pretty=format:"%B" "$range" | \
awk '!/Update changelog/ && NF {print "- " $0 "\n"}' >> "$out"
done
pre_tag=$(
curl --silent "https://api.github.com/repos/chen08209/FlClash/releases/latest" |
sed -nE 's/.*"tag_name": "([^"]+)".*/\1/p'
)
bash .github/scripts/generate_release_notes.sh "$pre_tag" release.md
- name: Push to telegram
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
+2 -1
View File
@@ -74,4 +74,5 @@ devtools_options.yaml
# FVM Version Cache
.fvm/
.fvmrc
.fvmrc
/coverage/lcov.info
+2 -8
View File
@@ -7,14 +7,8 @@ repos:
language: system
files: \.dart$
- id: flutter-analyze
name: flutter analyze
name: flutter analyze (pre-push)
entry: flutter analyze --no-fatal-infos
language: system
pass_filenames: false
files: \.dart$
- id: flutter-test
name: flutter test
entry: flutter test --reporter expanded
language: system
pass_filenames: false
files: \.dart$
stages: [pre-push]
+11
View File
@@ -21,9 +21,20 @@ Read these only when the task touches their area:
## 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.
- Do not add code or configuration comments unless the user explicitly asks for comments. This includes explanatory,
narrative, TODO, and documentation comments.
- 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.
- Follow `analysis_options.yaml`, especially single quotes, trailing commas, `child:` last, no `print()`, const/final
preferences, and declared return types.
- For CI parity, verify with `flutter pub get`, `flutter analyze --no-fatal-infos`, and
+3 -1
View File
@@ -4,6 +4,7 @@ PLATFORM ?= macos
BUILDKIT := plugins/setup/buildkit/run_build_tool.sh
ARCH_ARG := $(if $(ARCH),--arch $(ARCH),)
TARGET_PLATFORM_ARG := $(if $(TARGET_PLATFORM),--target-platform $(TARGET_PLATFORM),)
FORCE_ARG := $(if $(filter 1 true yes,$(FORCE)),--force,)
.PHONY: help submodules core core-macos core-linux core-windows core-android
@@ -13,12 +14,13 @@ help:
@echo 'make core-macos ARCH=arm64'
@echo 'make core-android ARCH=arm64'
@echo 'make core-android TARGET_PLATFORM=android-arm64'
@echo 'make core-macos FORCE=1 # bypass setup build cache'
submodules:
git submodule update --init --recursive
core:
bash $(BUILDKIT) $(PLATFORM) $(ARCH_ARG) $(TARGET_PLATFORM_ARG)
bash $(BUILDKIT) $(PLATFORM) $(ARCH_ARG) $(TARGET_PLATFORM_ARG) $(FORCE_ARG)
core-macos:
$(MAKE) core PLATFORM=macos
+17 -17
View File
@@ -15,21 +15,20 @@ val localProperties = Properties().apply {
}
}
val mStoreFile: File = file("keystore.jks")
val mStorePassword: String? = localProperties.getProperty("storePassword")
val mKeyAlias: String? = localProperties.getProperty("keyAlias")
val mKeyPassword: String? = localProperties.getProperty("keyPassword")
val isRelease =
mStoreFile.exists() && mStorePassword != null && mKeyAlias != null && mKeyPassword != null
val releaseStoreFile = file("keystore.jks")
val releaseStorePassword = localProperties.getProperty("storePassword")
val releaseKeyAlias = localProperties.getProperty("keyAlias")
val releaseKeyPassword = localProperties.getProperty("keyPassword")
val hasReleaseSigning = releaseStoreFile.exists() &&
releaseStorePassword != null &&
releaseKeyAlias != null &&
releaseKeyPassword != null
android {
namespace = "com.follow.clash"
compileSdk = libs.versions.compileSdk.get().toInt()
ndkVersion = libs.versions.ndkVersion.get()
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
@@ -44,12 +43,12 @@ android {
}
signingConfigs {
if (isRelease) {
if (hasReleaseSigning) {
create("release") {
storeFile = mStoreFile
storePassword = mStorePassword
keyAlias = mKeyAlias
keyPassword = mKeyPassword
storeFile = releaseStoreFile
storePassword = releaseStorePassword
keyAlias = releaseKeyAlias
keyPassword = releaseKeyPassword
}
}
}
@@ -69,7 +68,7 @@ android {
release {
isMinifyEnabled = true
isShrinkResources = true
if (isRelease) {
if (hasReleaseSigning) {
signingConfig = signingConfigs.getByName("release")
} else {
signingConfig = signingConfigs.getByName("debug")
@@ -77,7 +76,8 @@ android {
}
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
@@ -93,10 +93,10 @@ flutter {
source = "../.."
}
dependencies {
implementation(project(":service"))
implementation(project(":common"))
implementation(project(":core"))
implementation(libs.core.splashscreen)
implementation(libs.gson)
implementation(libs.smali.dexlib2) {
+2 -2
View File
@@ -1,4 +1,4 @@
-keep class com.follow.clash.models.**{ *; }
-keep class com.follow.clash.models.** { *; }
-keep class com.follow.clash.service.models.**{ *; }
-keep class com.follow.clash.service.models.** { *; }
@@ -1,9 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<application
android:icon="@mipmap/ic_launcher"
android:label="FlClash Debug"
+11 -10
View File
@@ -11,10 +11,12 @@
<uses-feature
android:name="android.software.leanback"
android:required="false" />
<uses-feature
android:name="android.hardware.wifi"
android:required="false" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
@@ -23,15 +25,14 @@
tools:ignore="QueryAllPackagesPermission" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.POST_PROMOTED_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<application
android:name=".Application"
android:name=".FlClashApplication"
android:banner="@mipmap/ic_banner"
android:hardwareAccelerated="true"
android:icon="@mipmap/ic_launcher"
android:label="FlClash">
android:label="@string/app_name">
<activity
android:name=".MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
@@ -68,7 +69,7 @@
</activity>
<activity
android:name=".TempActivity"
android:name=".QuickActionActivity"
android:excludeFromRecents="true"
android:exported="true"
android:theme="@style/TransparentTheme">
@@ -90,7 +91,7 @@
android:name=".TileService"
android:exported="true"
android:icon="@drawable/ic"
android:label="FlClash"
android:label="@string/app_name"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
@@ -101,13 +102,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>
@@ -115,4 +116,4 @@
android:name="flutterEmbedding"
android:value="2" />
</application>
</manifest>
</manifest>
@@ -1,29 +0,0 @@
package com.follow.clash
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.follow.clash.common.BroadcastAction
import com.follow.clash.common.GlobalState
import com.follow.clash.common.action
import kotlinx.coroutines.launch
class BroadcastReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
BroadcastAction.SERVICE_CREATED.action -> {
GlobalState.log("Receiver service created")
GlobalState.launch {
State.handleStartServiceAction()
}
}
BroadcastAction.SERVICE_DESTROYED.action -> {
GlobalState.log("Receiver service destroyed")
GlobalState.launch {
State.handleStopServiceAction()
}
}
}
}
}
@@ -17,37 +17,32 @@ import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.util.concurrent.TimeUnit
import kotlin.coroutines.resume
private const val ICON_TTL_DAYS = 1L
val Application.sharedState: SharedState
get() {
try {
val sp = getSharedPreferences("FlutterSharedPreferences", MODE_PRIVATE)
val res = sp.getString("flutter.sharedState", "")
return Gson().fromJson(res, SharedState::class.java)
} catch (_: Exception) {
return SharedState()
}
get() = try {
val preferences = getSharedPreferences("FlutterSharedPreferences", MODE_PRIVATE)
val json = preferences.getString("flutter.sharedState", null)
Gson().fromJson(json, SharedState::class.java) ?: SharedState()
} catch (_: Exception) {
SharedState()
}
private var lastToast: Toast? = null
fun Application.showToast(text: String?) {
if (text.isNullOrEmpty()) return
Handler(Looper.getMainLooper()).post {
lastToast?.cancel()
lastToast = Toast.makeText(this, text, Toast.LENGTH_LONG).apply {
show()
}
}
}
suspend fun PackageManager.getPackageIconPath(packageName: String): String =
@@ -55,8 +50,7 @@ suspend fun PackageManager.getPackageIconPath(packageName: String): String =
val cacheDir = GlobalState.application.cacheDir
val iconDir = File(cacheDir, "icons").apply { mkdirs() }
return@withContext try {
val pkgInfo = getPackageInfo(packageName, 0)
val lastUpdateTime = pkgInfo.lastUpdateTime
val lastUpdateTime = getPackageInfo(packageName, 0).lastUpdateTime
val iconFile = File(iconDir, "${packageName}_${lastUpdateTime}.webp")
if (iconFile.exists() && !isExpired(iconFile)) {
return@withContext iconFile.absolutePath
@@ -80,14 +74,11 @@ private suspend fun saveDrawableToFile(drawable: Drawable, file: File) {
drawable.toBitmap(width = 128, height = 128)
}
try {
val format = when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> {
Bitmap.CompressFormat.WEBP_LOSSY
}
else -> {
Bitmap.CompressFormat.WEBP
}
@Suppress("DEPRECATION")
val format = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
Bitmap.CompressFormat.WEBP_LOSSY
} else {
Bitmap.CompressFormat.WEBP
}
FileOutputStream(file).use { fos ->
bitmap.compress(format, 90, fos)
@@ -103,48 +94,12 @@ private fun isExpired(file: File): Boolean {
return age > TimeUnit.DAYS.toMillis(ICON_TTL_DAYS)
}
suspend fun <T> MethodChannel.awaitResult(
method: String, arguments: Any? = null
): T? = withContext(Dispatchers.Main) {
suspendCancellableCoroutine { continuation ->
invokeMethod(method, arguments, object : MethodChannel.Result {
override fun success(result: Any?) {
@Suppress("UNCHECKED_CAST") continuation.resume(result as T?)
}
override fun error(code: String, message: String?, details: Any?) {
continuation.resume(null)
}
override fun notImplemented() {
continuation.resume(null)
}
})
}
}
inline fun <reified T : FlutterPlugin> FlutterEngine.plugin(): T? {
return plugins.get(T::class.java) as T?
}
fun <T> MethodChannel.invokeMethodOnMainThread(
method: String, arguments: Any? = null, callback: ((Result<T>) -> Unit)? = null
) {
fun MethodChannel.invokeMethodOnMainThread(method: String, arguments: Any? = null) {
Handler(Looper.getMainLooper()).post {
invokeMethod(method, arguments, object : MethodChannel.Result {
override fun success(result: Any?) {
@Suppress("UNCHECKED_CAST") callback?.invoke(Result.success(result as T))
}
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
val exception = Exception("MethodChannel error: $errorCode - $errorMessage")
callback?.invoke(Result.failure(exception))
}
override fun notImplemented() {
val exception = NotImplementedError("Method not implemented: $method")
callback?.invoke(Result.failure(exception))
}
})
invokeMethod(method, arguments)
}
}
@@ -4,8 +4,7 @@ import android.app.Application
import android.content.Context
import com.follow.clash.common.GlobalState
class Application : Application() {
class FlClashApplication : Application() {
override fun attachBaseContext(base: Context?) {
super.attachBaseContext(base)
GlobalState.init(this)
@@ -1,37 +1,22 @@
package com.follow.clash
import android.os.Bundle
import com.follow.clash.common.GlobalState
import com.follow.clash.plugins.AppPlugin
import com.follow.clash.plugins.ServicePlugin
import com.follow.clash.plugins.TilePlugin
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
class MainActivity : FlutterActivity(),
CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
}
class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
flutterEngine.plugins.add(AppPlugin())
flutterEngine.plugins.add(ServicePlugin())
flutterEngine.plugins.add(TilePlugin())
State.flutterEngine = flutterEngine
ServiceState.attachFlutterEngine(flutterEngine)
}
override fun onDestroy() {
GlobalState.launch {
Service.setEventListener(null)
}
State.flutterEngine = null
flutterEngine?.let(ServiceState::detachFlutterEngine)
super.onDestroy()
}
}
}
@@ -0,0 +1,28 @@
package com.follow.clash
import android.app.Activity
import android.os.Bundle
import androidx.core.content.pm.ShortcutManagerCompat
import com.follow.clash.common.GlobalState
import com.follow.clash.common.QuickAction
import com.follow.clash.common.action
import kotlinx.coroutines.launch
class QuickActionActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
when (intent.action) {
QuickAction.START.action -> GlobalState.launch { ServiceState.handleStartAction() }
QuickAction.STOP.action -> GlobalState.launch { ServiceState.handleStopAction() }
QuickAction.TOGGLE.action -> {
ShortcutManagerCompat.reportShortcutUsed(this, SHORTCUT_ID)
GlobalState.launch { ServiceState.handleToggleAction() }
}
}
finish()
}
private companion object {
const val SHORTCUT_ID = "toggle"
}
}
@@ -1,187 +0,0 @@
package com.follow.clash
import com.follow.clash.common.GlobalState
import com.follow.clash.common.ServiceDelegate
import com.follow.clash.common.formatString
import com.follow.clash.common.intent
import com.follow.clash.service.IAckInterface
import com.follow.clash.service.ICallbackInterface
import com.follow.clash.service.IEventInterface
import com.follow.clash.service.IRemoteInterface
import com.follow.clash.service.IResultInterface
import com.follow.clash.service.IVoidInterface
import com.follow.clash.service.RemoteService
import com.follow.clash.service.models.NotificationParams
import com.follow.clash.service.models.VpnOptions
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
object Service {
private val delegate by lazy {
ServiceDelegate<IRemoteInterface>(
RemoteService::class.intent, ::handleServiceDisconnected
) {
IRemoteInterface.Stub.asInterface(it)
}
}
var onServiceDisconnected: ((String) -> Unit)? = null
private fun handleServiceDisconnected(message: String) {
onServiceDisconnected?.let {
it(message)
}
}
fun bind() {
delegate.bind()
}
fun unbind() {
delegate.unbind()
}
suspend fun invokeAction(data: String, cb: ((result: String) -> Unit)?): Result<Unit> {
val res = mutableListOf<ByteArray>()
return delegate.useService {
it.invokeAction(
data, object : ICallbackInterface.Stub() {
override fun onResult(
result: ByteArray?, isSuccess: Boolean, ack: IAckInterface?
) {
res.add(result ?: byteArrayOf())
ack?.onAck()
if (isSuccess) {
cb?.let { cb ->
cb(res.formatString())
}
}
}
})
}
}
suspend fun quickSetup(
initParamsString: String,
setupParamsString: String,
onStarted: (() -> Unit)?,
onResult: ((result: String) -> Unit)?,
): Result<Unit> {
val res = mutableListOf<ByteArray>()
return delegate.useService {
it.quickSetup(
initParamsString,
setupParamsString,
object : ICallbackInterface.Stub() {
override fun onResult(
result: ByteArray?, isSuccess: Boolean, ack: IAckInterface?
) {
res.add(result ?: byteArrayOf())
ack?.onAck()
if (isSuccess) {
onResult?.let { cb ->
cb(res.formatString())
}
}
}
},
object : IVoidInterface.Stub() {
override fun invoke() {
onStarted?.let { onStarted ->
onStarted()
}
}
}
)
}
}
suspend fun setEventListener(
cb: ((result: String?) -> Unit)?
): Result<Unit> {
val results = HashMap<String, MutableList<ByteArray>>()
return delegate.useService {
it.setEventListener(
when (cb != null) {
true -> object : IEventInterface.Stub() {
override fun onEvent(
id: String, data: ByteArray?, isSuccess: Boolean, ack: IAckInterface?
) {
if (results[id] == null) {
results[id] = mutableListOf()
}
results[id]?.add(data ?: byteArrayOf())
ack?.onAck()
if (isSuccess) {
cb(results[id]?.formatString())
results.remove(id)
}
}
}
false -> null
})
}
}
suspend fun updateNotificationParams(
params: NotificationParams
): Result<Unit> {
return delegate.useService {
it.updateNotificationParams(params)
}
}
suspend fun setCrashlytics(
enable: Boolean
): Result<Unit> {
return delegate.useService {
it.setCrashlytics(enable)
}
}
private suspend fun awaitIResultInterface(
block: (IResultInterface) -> Unit
): Long = suspendCancellableCoroutine { continuation ->
val callback = object : IResultInterface.Stub() {
override fun onResult(time: Long) {
if (continuation.isActive) {
continuation.resume(time)
}
}
}
try {
block(callback)
} catch (e: Exception) {
GlobalState.log("awaitIResultInterface $e")
if (continuation.isActive) {
continuation.resumeWithException(e)
}
}
}
suspend fun startService(options: VpnOptions, runTime: Long): Long {
return delegate.useService {
awaitIResultInterface { callback ->
it.startService(options, runTime, callback)
}
}.getOrNull() ?: 0L
}
suspend fun stopService(): Long {
return delegate.useService {
awaitIResultInterface { callback ->
it.stopService(callback)
}
}.getOrNull() ?: 0L
}
suspend fun getRunTime(): Long {
return delegate.useService {
it.runTime
}.getOrNull() ?: 0L
}
}
@@ -0,0 +1,58 @@
package com.follow.clash
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Handler
import android.os.Looper
import com.follow.clash.common.BroadcastAction
import com.follow.clash.common.GlobalState
import com.follow.clash.common.action
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.launch
class ServiceBroadcastReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val action = intent?.action ?: return
val pendingResult = goAsync()
val finished = AtomicBoolean(false)
val timeout = Runnable {
if (finished.compareAndSet(false, true)) {
GlobalState.log("Broadcast handling timed out: $action")
pendingResult.finish()
}
}
mainHandler.postDelayed(timeout, BROADCAST_TIMEOUT_MILLIS)
GlobalState.launch {
try {
handleAction(action)
} catch (error: Exception) {
GlobalState.log("Unable to handle service broadcast $action: $error")
} finally {
mainHandler.removeCallbacks(timeout)
if (finished.compareAndSet(false, true)) {
pendingResult.finish()
}
}
}
}
private suspend fun handleAction(action: String) {
when (action) {
BroadcastAction.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,232 @@
package com.follow.clash
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import com.follow.clash.common.GlobalState
import com.follow.clash.common.intent
import com.follow.clash.core.Core
import com.follow.clash.service.ManagedService
import com.follow.clash.service.ProxyService
import com.follow.clash.service.ServiceConfig
import com.follow.clash.service.VpnService
import com.follow.clash.service.models.VpnOptions
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlin.coroutines.resume
object ServiceController {
private val lock = Mutex()
private var binding: ManagedServiceBinding? = null
@Volatile
private var runTimeMillis = 0L
suspend fun unbind() = lock.withLock {
clearBinding()
}
private fun clearBinding() {
binding?.unbind()
binding = null
}
fun invokeMethod(data: String, callback: (String) -> Unit): Result<Unit> = runCatching {
Core.invokeMethod(data) { result ->
callback(result.orEmpty())
}
}
suspend fun quickSetup(
initParams: String,
setupParams: String,
): Result<String> = runCatching {
suspendCancellableCoroutine { continuation ->
Core.quickSetup(initParams, setupParams) { result ->
continuation.resume(result.orEmpty())
}
}
}
fun setEventListener(callback: ((String?) -> Unit)?): Result<Unit> = runCatching {
Core.updateEventListener(callback)
}
suspend fun start(options: VpnOptions): Long = lock.withLock {
ServiceConfig.updateVpnOptions(options)
val nextIntent = if (options.enable) {
VpnService::class.intent
} else {
ProxyService::class.intent
}
if (binding?.component != nextIntent.component) {
clearBinding()
lateinit var nextBinding: ManagedServiceBinding
nextBinding = ManagedServiceBinding(nextIntent) { message ->
handleServiceDisconnected(nextBinding, message)
}
binding = nextBinding
nextBinding.bind().onFailure { error ->
GlobalState.log("Unable to bind background service: $error")
clearBinding()
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 {
binding?.useService { service -> service.stop() }
?.onFailure { error ->
GlobalState.log("Unable to stop background service: $error")
}
clearBinding()
runTimeMillis = 0L
}
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,323 @@
package com.follow.clash
import android.net.VpnService
import com.follow.clash.common.GlobalState
import com.follow.clash.models.SharedState
import com.follow.clash.plugins.AppPlugin
import com.follow.clash.plugins.TilePlugin
import com.follow.clash.service.ServiceConfig
import com.follow.clash.service.models.NotificationParams
import com.follow.clash.service.models.VpnOptions
import com.google.gson.Gson
import io.flutter.embedding.engine.FlutterEngine
import java.util.concurrent.atomic.AtomicReference
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlin.coroutines.resume
enum class RunState {
STARTED,
STARTING,
STOPPING,
STOPPED,
}
private const val MISSING_CONFIG_MESSAGE = "No configuration found."
private const val INVALID_CONFIG_MESSAGE = "Invalid configuration."
private const val VPN_PERMISSION_MESSAGE = "VPN permission required."
private const val START_FAILED_MESSAGE = "Failed to start service."
object ServiceState {
private val transitionLock = Mutex()
private val startPreparationLock = Mutex()
private val mutableRunState = MutableStateFlow(RunState.STOPPED)
private val latestRequest = AtomicReference(RunRequest(running = false))
@Volatile
private var sharedState = SharedState()
@Volatile
private var flutterEngine: FlutterEngine? = null
val runState = mutableRunState.asStateFlow()
private val runTimeMillis: Long
get() = ServiceController.getRunTimeMillis()
private val appPlugin: AppPlugin?
get() = flutterEngine?.plugin<AppPlugin>()
private val tilePlugin: TilePlugin?
get() = flutterEngine?.plugin<TilePlugin>()
fun attachFlutterEngine(engine: FlutterEngine) {
flutterEngine = engine
}
fun detachFlutterEngine(engine: FlutterEngine) {
if (flutterEngine === engine) {
flutterEngine = null
}
}
suspend fun handleToggleAction() {
if (isRunningRequested()) {
handleStopAction()
} else {
handleStartAction()
}
}
suspend fun refresh(): Long = transitionLock.withLock {
val current = runTimeMillis
mutableRunState.value = if (current == 0L) RunState.STOPPED else RunState.STARTED
current
}
internal fun captureRequestToken(): RunRequest = latestRequest.get()
/**
* 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.
*/
internal suspend fun handleServiceLost(token: RunRequest) = transitionLock.withLock {
if (runTimeMillis != 0L) {
return@withLock
}
if (!latestRequest.compareAndSet(token, RunRequest(running = false))) {
return@withLock
}
mutableRunState.value = RunState.STOPPED
}
suspend fun handleStartAction() {
if (isRunningRequested()) {
return
}
val plugin = tilePlugin
if (plugin != null) {
plugin.handleStart()
return
}
loadPreferencesAndStart()
}
suspend fun handleStopAction() {
if (!isRunningRequested()) {
return
}
val plugin = tilePlugin
if (plugin != null) {
plugin.handleStop()
return
}
GlobalState.application.showToast(sharedState.stopTip)
requestStop().await()
}
suspend fun handleVpnRevokeAction() {
if (!ServiceController.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 {
GlobalState.launch {
result.complete(
runCatching { start(request) }
.onFailure { error ->
GlobalState.log("Unable to process service start request: $error")
fail(request)
}
.getOrDefault(false),
)
}
}
}
val plugin = appPlugin
if (plugin != null) {
plugin.requestNotificationPermission(launchRequest)
} else {
launchRequest(true)
}
return result
}
fun requestStop(): Deferred<Boolean> {
val request = createRequest(running = false)
val result = CompletableDeferred<Boolean>()
GlobalState.launch {
result.complete(
runCatching { stop(request) }
.onFailure { error ->
GlobalState.log("Unable to process service stop request: $error")
}
.getOrDefault(false),
)
}
return result
}
fun syncSharedState(state: SharedState) {
sharedState = state
applySharedState()
}
private suspend fun loadPreferencesAndStart() {
sharedState = GlobalState.application.sharedState
if (sharedState.setupParams == null || sharedState.vpnOptions == null) {
GlobalState.application.showToast(MISSING_CONFIG_MESSAGE)
return
}
if (setupCore()) {
if (!requestStart().await()) {
GlobalState.application.showToast(START_FAILED_MESSAGE)
}
}
}
private fun applySharedState() {
GlobalState.setCrashlytics(sharedState.crashlytics)
ServiceConfig.updateNotificationParams(
NotificationParams(
title = sharedState.currentProfileName,
stopText = sharedState.stopText,
onlyStatisticsProxy = sharedState.onlyStatisticsProxy,
),
)
}
private suspend fun setupCore(): Boolean {
applySharedState()
GlobalState.application.showToast(sharedState.startTip)
val initParams = Gson().toJson(
mapOf(
"home-dir" to GlobalState.application.filesDir.path,
"version" to android.os.Build.VERSION.SDK_INT,
),
)
val setupParams = Gson().toJson(sharedState.setupParams)
return ServiceController.quickSetup(
initParams,
setupParams,
).fold(
onSuccess = { message ->
if (message.isEmpty()) {
true
} else {
GlobalState.log("Unable to set up core: $message")
showConfigError(message)
false
}
},
onFailure = { error ->
GlobalState.log("Unable to set up core: $error")
showConfigError(error.message)
false
},
)
}
private fun showConfigError(message: String?) {
GlobalState.application.showToast(
message?.takeIf { it.isNotBlank() } ?: INVALID_CONFIG_MESSAGE,
)
}
private suspend fun start(request: RunRequest): Boolean = startPreparationLock.withLock {
if (!isCurrent(request)) {
return@withLock false
}
val options = sharedState.vpnOptions
if (options == null) {
fail(request)
return@withLock false
}
if (!prepareVpn(options)) {
if (appPlugin == null && isCurrent(request)) {
GlobalState.application.showToast(VPN_PERMISSION_MESSAGE)
}
fail(request)
return@withLock false
}
if (!isCurrent(request)) {
return@withLock false
}
transitionLock.withLock transition@{
if (!isCurrent(request)) {
return@transition false
}
if (runState.value == RunState.STARTED && runTimeMillis != 0L) {
return@transition true
}
mutableRunState.value = RunState.STARTING
val startedAtMillis = ServiceController.start(options)
mutableRunState.value =
if (startedAtMillis == 0L) RunState.STOPPED else RunState.STARTED
if (startedAtMillis == 0L) {
fail(request)
return@transition false
}
isCurrent(request)
}
}
private suspend fun stop(request: RunRequest): Boolean = transitionLock.withLock {
if (!isCurrent(request)) {
return@withLock false
}
if (runState.value == RunState.STOPPED && runTimeMillis == 0L) {
return@withLock true
}
mutableRunState.value = RunState.STOPPING
ServiceController.stop()
mutableRunState.value = RunState.STOPPED
isCurrent(request)
}
private suspend fun prepareVpn(options: VpnOptions): Boolean {
val plugin = appPlugin
?: return !options.enable || VpnService.prepare(GlobalState.application) == null
return suspendCancellableCoroutine { continuation ->
val callback: (Boolean) -> Unit = { granted ->
if (continuation.isActive) {
continuation.resume(granted)
}
}
continuation.invokeOnCancellation {
plugin.cancelVpnPreparation(callback)
}
plugin.prepareVpn(options.enable, callback)
}
}
private fun createRequest(running: Boolean): RunRequest =
RunRequest(running).also(latestRequest::set)
private fun isRunningRequested(): Boolean = latestRequest.get().running
private fun isCurrent(request: RunRequest): Boolean = latestRequest.get() === request
private fun fail(request: RunRequest) {
latestRequest.compareAndSet(request, RunRequest(running = false))
}
internal class RunRequest(
val running: Boolean,
)
}
@@ -1,205 +0,0 @@
package com.follow.clash
import android.net.VpnService
import com.follow.clash.common.GlobalState
import com.follow.clash.models.SharedState
import com.follow.clash.plugins.AppPlugin
import com.follow.clash.plugins.TilePlugin
import com.follow.clash.service.models.NotificationParams
import com.google.gson.Gson
import io.flutter.embedding.engine.FlutterEngine
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
enum class RunState {
START, PENDING, STOP
}
object State {
val runLock = Mutex()
var runTime: Long = 0
var sharedState: SharedState = SharedState()
val runStateFlow: MutableStateFlow<RunState> = MutableStateFlow(RunState.STOP)
var flutterEngine: FlutterEngine? = null
val appPlugin: AppPlugin?
get() = flutterEngine?.plugin<AppPlugin>()
val tilePlugin: TilePlugin?
get() = flutterEngine?.plugin<TilePlugin>()
suspend fun handleToggleAction() {
var action: (suspend () -> Unit)?
runLock.withLock {
action = when (runStateFlow.value) {
RunState.PENDING -> null
RunState.START -> ::handleStopServiceAction
RunState.STOP -> ::handleStartServiceAction
}
}
action?.invoke()
}
suspend fun handleSyncState() {
runLock.withLock {
try {
Service.bind()
runTime = Service.getRunTime()
val runState = when (runTime == 0L) {
true -> RunState.STOP
false -> RunState.START
}
runStateFlow.tryEmit(runState)
} catch (_: Exception) {
runStateFlow.tryEmit(RunState.STOP)
}
}
}
suspend fun handleStartServiceAction() {
runLock.withLock {
if (runStateFlow.value != RunState.STOP) {
return
}
tilePlugin?.handleStart()
if (flutterEngine != null) {
return
}
startServiceWithPref()
}
}
suspend fun handleStopServiceAction() {
runLock.withLock {
if (runStateFlow.value != RunState.START) {
return
}
tilePlugin?.handleStop()
if (flutterEngine != null) {
return
}
GlobalState.application.showToast(sharedState.stopTip)
handleStopService()
}
}
fun handleStartService() {
val appPlugin = flutterEngine?.plugin<AppPlugin>()
if (appPlugin != null) {
appPlugin.requestNotificationsPermission {
startService()
}
return
}
startService()
}
private fun startServiceWithPref() {
GlobalState.launch {
runLock.withLock {
if (runStateFlow.value != RunState.STOP) {
return@launch
}
sharedState = GlobalState.application.sharedState
setupAndStart()
}
}
}
suspend fun syncState() {
GlobalState.setCrashlytics(sharedState.crashlytics)
Service.updateNotificationParams(
NotificationParams(
title = sharedState.currentProfileName,
stopText = sharedState.stopText,
onlyStatisticsProxy = sharedState.onlyStatisticsProxy
)
)
Service.setCrashlytics(sharedState.crashlytics)
}
private suspend fun setupAndStart() {
Service.bind()
syncState()
GlobalState.application.showToast(sharedState.startTip)
val initParams = mutableMapOf<String, Any>()
initParams["home-dir"] = GlobalState.application.filesDir.path
initParams["version"] = android.os.Build.VERSION.SDK_INT
val initParamsString = Gson().toJson(initParams)
val setupParamsString = Gson().toJson(sharedState.setupParams)
Service.quickSetup(
initParamsString,
setupParamsString,
onStarted = {
startService()
},
onResult = {
if (it.isNotEmpty()) {
GlobalState.application.showToast(it)
}
},
)
}
private fun startService() {
GlobalState.launch {
runLock.withLock {
if (runStateFlow.value != RunState.STOP) {
return@launch
}
try {
runStateFlow.tryEmit(RunState.PENDING)
val options = sharedState.vpnOptions ?: return@launch
appPlugin?.let {
it.prepare(options.enable) {
runTime = Service.startService(options, runTime)
runStateFlow.tryEmit(RunState.START)
}
} ?: run {
val intent = VpnService.prepare(GlobalState.application)
if (intent != null) {
return@launch
}
runTime = Service.startService(options, runTime)
runStateFlow.tryEmit(RunState.START)
}
} finally {
if (runStateFlow.value == RunState.PENDING) {
runStateFlow.tryEmit(RunState.STOP)
}
}
}
}
}
fun handleStopService() {
GlobalState.launch {
runLock.withLock {
if (runStateFlow.value != RunState.START) {
return@launch
}
try {
runStateFlow.tryEmit(RunState.PENDING)
runTime = Service.stopService()
runStateFlow.tryEmit(RunState.STOP)
} finally {
if (runStateFlow.value == RunState.PENDING) {
runStateFlow.tryEmit(RunState.START)
}
}
}
}
}
}
@@ -1,37 +0,0 @@
package com.follow.clash
import android.app.Activity
import android.os.Bundle
import com.follow.clash.common.QuickAction
import com.follow.clash.common.action
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
class TempActivity : Activity(),
CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
when (intent.action) {
QuickAction.START.action -> {
launch {
State.handleStartServiceAction()
}
}
QuickAction.STOP.action -> {
launch {
State.handleStopServiceAction()
}
}
QuickAction.TOGGLE.action -> {
launch {
State.handleToggleAction()
}
}
}
finish()
}
}
@@ -3,7 +3,6 @@ package com.follow.clash
import android.annotation.SuppressLint
import android.os.Build
import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import com.follow.clash.common.QuickAction
import com.follow.clash.common.quickIntent
import com.follow.clash.common.toPendingIntent
@@ -13,49 +12,50 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
class TileService : TileService() {
class TileService : android.service.quicksettings.TileService() {
private var scope: CoroutineScope? = null
private fun updateTile(runState: RunState) {
if (qsTile != null) {
qsTile.state = when (runState) {
RunState.START -> Tile.STATE_ACTIVE
RunState.PENDING -> Tile.STATE_UNAVAILABLE
RunState.STOP -> Tile.STATE_INACTIVE
}
qsTile.updateTile()
}
}
override fun onStartListening() {
super.onStartListening()
scope?.cancel()
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
scope?.launch {
State.handleSyncState()
State.runStateFlow.collect {
updateTile(it)
scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate).also { scope ->
scope.launch {
ServiceState.refresh()
ServiceState.runState.collect(::updateTile)
}
}
}
@SuppressLint("StartActivityAndCollapseDeprecated")
private fun handleToggle() {
val intent = QuickAction.TOGGLE.quickIntent
val pendingIntent = intent.toPendingIntent
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startActivityAndCollapse(pendingIntent)
} else {
@Suppress("DEPRECATION") startActivityAndCollapse(intent)
}
}
override fun onClick() {
super.onClick()
handleToggle()
openQuickAction()
}
override fun onStopListening() {
scope?.cancel()
scope = null
super.onStopListening()
}
}
private fun updateTile(runState: RunState) {
qsTile?.apply {
state = when (runState) {
RunState.STARTED -> Tile.STATE_ACTIVE
RunState.STARTING, RunState.STOPPING -> Tile.STATE_UNAVAILABLE
RunState.STOPPED -> Tile.STATE_INACTIVE
}
updateTile()
}
}
@SuppressLint("StartActivityAndCollapseDeprecated")
private fun openQuickAction() {
val intent = QuickAction.TOGGLE.quickIntent
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startActivityAndCollapse(intent.toPendingIntent)
} else {
@Suppress("DEPRECATION")
startActivityAndCollapse(intent)
}
}
}
@@ -1,6 +1,6 @@
package com.follow.clash.models
data class Package(
data class InstalledPackage(
val packageName: String,
val label: String,
val system: Boolean,
@@ -0,0 +1,174 @@
package com.follow.clash.packages
import android.Manifest
import android.content.pm.ApplicationInfo
import android.content.pm.ComponentInfo
import android.content.pm.PackageManager
import android.os.Build
import com.android.tools.smali.dexlib2.dexbacked.DexBackedDexFile
import com.follow.clash.models.InstalledPackage
import java.io.File
import java.util.zip.ZipFile
internal class PackageResolver(
private val packageManager: PackageManager,
private val appPackageName: String,
) {
val installedPackages: List<InstalledPackage> by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
loadPackages()
}
fun getChinaPackageNames(): List<String> = installedPackages
.map { it.packageName }
.filter(::isChinaPackage)
private fun loadPackages(): List<InstalledPackage> {
val flags = PackageManager.GET_PERMISSIONS
val packages = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
packageManager.getInstalledPackages(
PackageManager.PackageInfoFlags.of(flags.toLong()),
)
} else {
@Suppress("DEPRECATION")
packageManager.getInstalledPackages(flags)
}
return packages.asSequence()
.filter { info ->
info.packageName != appPackageName && info.packageName != ANDROID_PACKAGE_NAME
}
.map { info ->
InstalledPackage(
packageName = info.packageName,
label = info.applicationInfo?.loadLabel(packageManager)?.toString()
?: info.packageName,
system = info.applicationInfo?.let { applicationInfo ->
applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM != 0
} == true,
internet = info.requestedPermissions
?.contains(Manifest.permission.INTERNET) == true,
lastUpdateTime = info.lastUpdateTime,
)
}.toList()
}
private fun isChinaPackage(packageName: String): Boolean {
if (SKIPPED_PREFIXES.any { packageName == it || packageName.startsWith("$it.") }) {
return false
}
if (packageName.matches(CHINA_PACKAGE_REGEX)) {
return true
}
return runCatching {
val packageInfo = getPackageInfo(packageName)
packageInfo.componentNames().any { it.matches(CHINA_PACKAGE_REGEX) } ||
packageInfo.applicationInfo?.publicSourceDir?.let(::scanArchive) == true
}.getOrDefault(false)
}
private fun getPackageInfo(packageName: String) = if (
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
) {
packageManager.getPackageInfo(
packageName,
PackageManager.PackageInfoFlags.of(PACKAGE_INFO_FLAGS.toLong()),
)
} else {
@Suppress("DEPRECATION")
packageManager.getPackageInfo(packageName, PACKAGE_INFO_FLAGS)
}
private fun android.content.pm.PackageInfo.componentNames(): Sequence<String> = sequence {
yieldAll(services.orEmpty().asSequence().map(ComponentInfo::name))
yieldAll(activities.orEmpty().asSequence().map(ComponentInfo::name))
yieldAll(receivers.orEmpty().asSequence().map(ComponentInfo::name))
yieldAll(providers.orEmpty().asSequence().map(ComponentInfo::name))
}
private fun scanArchive(sourcePath: String): Boolean = ZipFile(File(sourcePath)).use { archive ->
if (archive.entries().asSequence().any { it.name.startsWith("firebase-") }) {
return false
}
archive.entries().asSequence()
.filter { entry ->
entry.name.startsWith("classes") && entry.name.endsWith(".dex")
}.any { entry ->
if (entry.size > MAX_DEX_SIZE_BYTES) {
return@any true
}
val dexFile = archive.getInputStream(entry).buffered().use { input ->
DexBackedDexFile.fromInputStream(null, input)
}
dexFile.classes.any { clazz ->
clazz.type
.removeSurrounding("L", ";")
.replace('/', '.')
.replace('$', '.')
.matches(CHINA_PACKAGE_REGEX)
}
}
}
private companion object {
const val ANDROID_PACKAGE_NAME = "android"
const val MAX_DEX_SIZE_BYTES = 15_000_000L
val PACKAGE_INFO_FLAGS = PackageManager.GET_ACTIVITIES or
PackageManager.GET_SERVICES or
PackageManager.GET_RECEIVERS or
PackageManager.GET_PROVIDERS
val SKIPPED_PREFIXES = listOf(
"com.google",
"com.android.chrome",
"com.android.vending",
"com.microsoft",
"com.apple",
"com.zhiliaoapp.musically",
)
val CHINA_PACKAGE_REGEX = listOf(
"com.tencent",
"com.alibaba",
"com.umeng",
"com.qihoo",
"com.ali",
"com.alipay",
"com.amap",
"com.sina",
"com.weibo",
"com.vivo",
"com.xiaomi",
"com.huawei",
"com.taobao",
"com.secneo",
"s.h.e.l.l",
"com.stub",
"com.kiwisec",
"com.secshell",
"com.wrapper",
"cn.securitystack",
"com.mogosec",
"com.secoen",
"com.netease",
"com.mx",
"com.qq.e",
"com.baidu",
"com.bytedance",
"com.bugly",
"com.miui",
"com.oppo",
"com.coloros",
"com.iqoo",
"com.meizu",
"com.gionee",
"cn.nubia",
"com.oplus",
"andes.oplus",
"com.unionpay",
"cn.wps",
).joinToString("|", prefix = "(", postfix = ").*") { prefix ->
Regex.escape(prefix)
}.toRegex()
}
}
@@ -1,11 +1,10 @@
package com.follow.clash.plugins
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.app.ActivityManager
import android.content.Intent
import android.content.pm.ApplicationInfo
import android.content.pm.ComponentInfo
import android.content.pm.PackageManager
import android.net.VpnService
import android.os.Build
@@ -18,17 +17,15 @@ import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.core.net.toUri
import com.android.tools.smali.dexlib2.dexbacked.DexBackedDexFile
import com.follow.clash.R
import com.follow.clash.common.Components
import com.follow.clash.common.GlobalState
import com.follow.clash.common.QuickAction
import com.follow.clash.common.quickIntent
import com.follow.clash.getPackageIconPath
import com.follow.clash.models.Package
import com.follow.clash.packages.PackageResolver
import com.follow.clash.showToast
import com.google.gson.Gson
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
@@ -37,93 +34,39 @@ import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.Result
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.lang.ref.WeakReference
import java.util.zip.ZipFile
class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware {
companion object {
const val VPN_PERMISSION_REQUEST_CODE = 1001
const val NOTIFICATION_PERMISSION_REQUEST_CODE = 1002
}
private var activityRef: WeakReference<Activity>? = null
private var activity: Activity? = null
private lateinit var channel: MethodChannel
private lateinit var scope: CoroutineScope
private var vpnPrepareCallback: (suspend () -> Unit)? = null
private var vpnPrepareCallback: ((Boolean) -> Unit)? = null
private var requestNotificationCallback: (() -> Unit)? = null
private var requestNotificationCallback: ((Boolean) -> Unit)? = null
private val packages = mutableListOf<Package>()
private var isRequestingNotificationPermission = false
private val skipPrefixList = listOf(
"com.google",
"com.android.chrome",
"com.android.vending",
"com.microsoft",
"com.apple",
"com.zhiliaoapp.musically", // Banned by China
)
private val gson = Gson()
private val chinaAppPrefixList = listOf(
"com.tencent",
"com.alibaba",
"com.umeng",
"com.qihoo",
"com.ali",
"com.alipay",
"com.amap",
"com.sina",
"com.weibo",
"com.vivo",
"com.xiaomi",
"com.huawei",
"com.taobao",
"com.secneo",
"s.h.e.l.l",
"com.stub",
"com.kiwisec",
"com.secshell",
"com.wrapper",
"cn.securitystack",
"com.mogosec",
"com.secoen",
"com.netease",
"com.mx",
"com.qq.e",
"com.baidu",
"com.bytedance",
"com.bugly",
"com.miui",
"com.oppo",
"com.coloros",
"com.iqoo",
"com.meizu",
"com.gionee",
"cn.nubia",
"com.oplus",
"andes.oplus",
"com.unionpay",
"cn.wps"
)
private val chinaAppRegex by lazy {
("(" + chinaAppPrefixList.joinToString("|").replace(".", "\\.") + ").*").toRegex()
private val packageResolver by lazy {
PackageResolver(
GlobalState.application.packageManager,
GlobalState.application.packageName,
)
}
private var isBlockNotification: Boolean = false
private var skipNotificationPermissionRequest = false
override fun onMethodCall(call: MethodCall, result: Result) {
when (call.method) {
"moveTaskToBack" -> {
activityRef?.get()?.moveTaskToBack(true)
activity?.moveTaskToBack(true)
result.success(true)
}
@@ -134,19 +77,24 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
}
"initShortcuts" -> {
initShortcuts(call.arguments as String)
result.success(true)
val label = call.arguments as? String
if (label == null) {
result.error("INVALID_ARGUMENT", "Shortcut label must be a string", null)
} else {
initShortcuts(label)
result.success(true)
}
}
"getPackages" -> {
scope.launch {
result.success(getPackagesToJson())
scope.launch(Dispatchers.IO) {
result.success(gson.toJson(packageResolver.installedPackages))
}
}
"getChinaPackageNames" -> {
scope.launch {
result.success(getChinaPackageNames())
scope.launch(Dispatchers.IO) {
result.success(gson.toJson(packageResolver.getChinaPackageNames()))
}
}
@@ -156,7 +104,7 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
"tip" -> {
val message = call.argument<String>("message")
tip(message)
GlobalState.application.showToast(message)
result.success(true)
}
@@ -172,6 +120,10 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
result.success(openAppSettings())
}
"didCrashOnPreviousExecution" -> {
result.success(GlobalState.didCrashOnPreviousExecution())
}
else -> {
result.notImplemented()
}
@@ -197,32 +149,32 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
IconCompat.createWithResource(
GlobalState.application,
R.mipmap.ic_launcher_round,
)
),
)
setIntent(QuickAction.TOGGLE.quickIntent)
build()
}
ShortcutManagerCompat.setDynamicShortcuts(
GlobalState.application, listOf(shortcut)
GlobalState.application,
listOf(shortcut),
)
}
private fun tip(message: String?) {
GlobalState.application.showToast(message)
}
private fun isBatteryOptimizationDisabled(): Boolean {
val powerManager = getSystemService(GlobalState.application, PowerManager::class.java)
return powerManager?.isIgnoringBatteryOptimizations(GlobalState.application.packageName)
?: false
}
@SuppressLint("BatteryLife")
private fun openBatteryOptimizationSettings(): Boolean {
// VPN continuity is the user-requested core function, so the direct exemption is intentional.
val activity = activity ?: return false
return try {
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = "package:${GlobalState.application.packageName}".toUri()
}
activityRef?.get()?.startActivity(intent)
activity.startActivity(intent)
true
} catch (_: Exception) {
false
@@ -230,11 +182,12 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
}
private fun openAppSettings(): Boolean {
val activity = activity ?: return false
return try {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = "package:${GlobalState.application.packageName}".toUri()
}
activityRef?.get()?.startActivity(intent)
activity.startActivity(intent)
true
} catch (_: Exception) {
false
@@ -246,170 +199,82 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
val am = getSystemService(GlobalState.application, ActivityManager::class.java)
val task = am?.appTasks?.firstOrNull {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
it.taskInfo.taskId == activityRef?.get()?.taskId
it.taskInfo.taskId == activity?.taskId
} else {
it.taskInfo.id == activityRef?.get()?.taskId
it.taskInfo.id == activity?.taskId
}
}
when (value) {
true -> task?.setExcludeFromRecents(value)
false -> task?.setExcludeFromRecents(value)
null -> task?.setExcludeFromRecents(false)
}
task?.setExcludeFromRecents(value ?: false)
}
private fun getPackages(): List<Package> {
val packageManager = GlobalState.application.packageManager
if (packages.isNotEmpty()) return packages
packageManager?.getInstalledPackages(PackageManager.GET_META_DATA or PackageManager.GET_PERMISSIONS)
?.filter {
it.packageName != GlobalState.application.packageName && it.packageName != "android"
}?.map {
Package(
packageName = it.packageName,
label = it.applicationInfo?.loadLabel(packageManager).toString(),
system = (it.applicationInfo?.flags?.and(ApplicationInfo.FLAG_SYSTEM)) != 0,
lastUpdateTime = it.lastUpdateTime,
internet = it.requestedPermissions?.contains(Manifest.permission.INTERNET) == true
)
}?.let { packages.addAll(it) }
return packages
}
private suspend fun getPackagesToJson(): String {
return withContext(Dispatchers.Default) {
Gson().toJson(getPackages())
}
}
private suspend fun getChinaPackageNames(): String {
return withContext(Dispatchers.Default) {
val packages: List<String> =
getPackages().map { it.packageName }.filter { isChinaPackage(it) }
Gson().toJson(packages)
}
}
fun requestNotificationsPermission(callBack: () -> Unit) {
requestNotificationCallback = callBack
fun requestNotificationPermission(callback: (Boolean) -> Unit) {
requestNotificationCallback?.invoke(false)
requestNotificationCallback = callback
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val permission = ContextCompat.checkSelfPermission(
GlobalState.application, Manifest.permission.POST_NOTIFICATIONS
GlobalState.application,
Manifest.permission.POST_NOTIFICATIONS,
)
if (permission == PackageManager.PERMISSION_GRANTED || isBlockNotification) {
invokeRequestNotificationCallback()
if (permission == PackageManager.PERMISSION_GRANTED || skipNotificationPermissionRequest) {
invokeRequestNotificationCallback(true)
return
}
activityRef?.get()?.let {
if (isRequestingNotificationPermission) {
return
}
isRequestingNotificationPermission = true
activity?.let {
ActivityCompat.requestPermissions(
it,
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
NOTIFICATION_PERMISSION_REQUEST_CODE
NOTIFICATION_PERMISSION_REQUEST_CODE,
)
}
} ?: invokeRequestNotificationCallback(true)
return
} else {
invokeRequestNotificationCallback()
}
invokeRequestNotificationCallback(true)
}
fun invokeRequestNotificationCallback() {
requestNotificationCallback?.invoke()
private fun invokeRequestNotificationCallback(shouldStart: Boolean) {
isRequestingNotificationPermission = false
requestNotificationCallback?.invoke(shouldStart)
requestNotificationCallback = null
}
fun prepare(needPrepare: Boolean, callBack: (suspend () -> Unit)) {
vpnPrepareCallback = callBack
fun prepareVpn(needPrepare: Boolean, callback: (Boolean) -> Unit) {
invokeVpnPrepareCallback(false)
vpnPrepareCallback = callback
if (!needPrepare) {
invokeVpnPrepareCallback()
invokeVpnPrepareCallback(true)
return
}
val intent = VpnService.prepare(GlobalState.application)
if (intent != null) {
activityRef?.get()?.startActivityForResult(intent, VPN_PERMISSION_REQUEST_CODE)
val activity = activity
if (activity == null) {
invokeVpnPrepareCallback(false)
} else {
@Suppress("DEPRECATION")
activity.startActivityForResult(intent, VPN_PERMISSION_REQUEST_CODE)
}
return
}
invokeVpnPrepareCallback()
invokeVpnPrepareCallback(true)
}
fun invokeVpnPrepareCallback() {
GlobalState.launch {
vpnPrepareCallback?.invoke()
fun cancelVpnPreparation(callback: (Boolean) -> Unit) {
if (vpnPrepareCallback === callback) {
vpnPrepareCallback = null
}
}
@Suppress("DEPRECATION")
private fun isChinaPackage(packageName: String): Boolean {
val packageManager = GlobalState.application.packageManager ?: return false
skipPrefixList.forEach {
if (packageName == it || packageName.startsWith("$it.")) return false
}
val packageManagerFlags =
PackageManager.MATCH_UNINSTALLED_PACKAGES or PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES or PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS
if (packageName.matches(chinaAppRegex)) {
return true
}
try {
val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
packageManager.getPackageInfo(
packageName, PackageManager.PackageInfoFlags.of(packageManagerFlags.toLong())
)
} else {
packageManager.getPackageInfo(
packageName, packageManagerFlags
)
}
mutableListOf<ComponentInfo>().apply {
packageInfo.services?.let { addAll(it) }
packageInfo.activities?.let { addAll(it) }
packageInfo.receivers?.let { addAll(it) }
packageInfo.providers?.let { addAll(it) }
}.forEach {
if (it.name.matches(chinaAppRegex)) return true
}
packageInfo.applicationInfo?.publicSourceDir?.let {
ZipFile(File(it)).use {
for (packageEntry in it.entries()) {
if (packageEntry.name.startsWith("firebase-")) return false
}
for (packageEntry in it.entries()) {
if (!(packageEntry.name.startsWith("classes") && packageEntry.name.endsWith(
".dex"
))
) {
continue
}
if (packageEntry.size > 15000000) {
return true
}
val input = it.getInputStream(packageEntry).buffered()
val dexFile = try {
DexBackedDexFile.fromInputStream(null, input)
} catch (e: Exception) {
return false
}
for (clazz in dexFile.classes) {
val clazzName =
clazz.type.substring(1, clazz.type.length - 1).replace("/", ".")
.replace("$", ".")
if (clazzName.matches(chinaAppRegex)) return true
}
}
}
}
} catch (_: Exception) {
return false
}
return false
private fun invokeVpnPrepareCallback(granted: Boolean) {
vpnPrepareCallback?.invoke(granted)
vpnPrepareCallback = null
}
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
scope = CoroutineScope(Dispatchers.Default)
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
channel =
MethodChannel(flutterPluginBinding.binaryMessenger, "${Components.PACKAGE_NAME}/app")
channel.setMethodCallHandler(this)
@@ -418,43 +283,58 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel.setMethodCallHandler(null)
scope.cancel()
invokeVpnPrepareCallback(false)
invokeRequestNotificationCallback(false)
}
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activityRef = WeakReference(binding.activity)
attachToActivity(binding)
}
private fun attachToActivity(binding: ActivityPluginBinding) {
activity = binding.activity
binding.addActivityResultListener(::onActivityResult)
binding.addRequestPermissionsResultListener(::onRequestPermissionsResultListener)
}
override fun onDetachedFromActivityForConfigChanges() {
activityRef = null
activity = null
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
activityRef = WeakReference(binding.activity)
attachToActivity(binding)
}
override fun onDetachedFromActivity() {
channel.invokeMethod("exit", null)
activityRef = null
activity = null
invokeVpnPrepareCallback(false)
invokeRequestNotificationCallback(false)
}
private fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
if (requestCode == VPN_PERMISSION_REQUEST_CODE) {
if (resultCode == FlutterActivity.RESULT_OK) {
invokeVpnPrepareCallback()
}
if (requestCode != VPN_PERMISSION_REQUEST_CODE) {
return false
}
invokeVpnPrepareCallback(resultCode == Activity.RESULT_OK)
return true
}
private fun onRequestPermissionsResultListener(
requestCode: Int, permissions: Array<String>, grantResults: IntArray
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray,
): Boolean {
if (requestCode == NOTIFICATION_PERMISSION_REQUEST_CODE) {
isBlockNotification = true
if (requestCode != NOTIFICATION_PERMISSION_REQUEST_CODE) {
return false
}
invokeRequestNotificationCallback()
skipNotificationPermissionRequest = true
invokeRequestNotificationCallback(true)
return true
}
private companion object {
const val VPN_PERMISSION_REQUEST_CODE = 1001
const val NOTIFICATION_PERMISSION_REQUEST_CODE = 1002
}
}
@@ -1,10 +1,8 @@
package com.follow.clash.plugins
import com.follow.clash.RunState
import com.follow.clash.Service
import com.follow.clash.State
import com.follow.clash.ServiceController
import com.follow.clash.ServiceState
import com.follow.clash.common.Components
import com.follow.clash.invokeMethodOnMainThread
import com.follow.clash.models.SharedState
import com.google.gson.Gson
import io.flutter.embedding.engine.plugins.FlutterPlugin
@@ -13,127 +11,101 @@ import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
class ServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) {
private lateinit var flutterMethodChannel: MethodChannel
class ServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
private lateinit var channel: MethodChannel
private lateinit var scope: CoroutineScope
private val gson = Gson()
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
flutterMethodChannel = MethodChannel(
flutterPluginBinding.binaryMessenger, "${Components.PACKAGE_NAME}/service"
)
flutterMethodChannel.setMethodCallHandler(this)
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
channel = MethodChannel(binding.binaryMessenger, "${Components.PACKAGE_NAME}/service")
channel.setMethodCallHandler(this)
}
override fun onDetachedFromEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
flutterMethodChannel.setMethodCallHandler(null)
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel.setMethodCallHandler(null)
scope.cancel()
ServiceController.setEventListener(null)
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) = when (call.method) {
"init" -> {
handleInit(result)
}
"shutdown" -> {
handleShutdown(result)
}
"invokeAction" -> {
handleInvokeAction(call, result)
}
"getRunTime" -> {
handleGetRunTime(result)
}
"syncState" -> {
handleSyncState(call, result)
}
"start" -> {
handleStart(result)
}
"stop" -> {
handleStop(result)
}
else -> {
result.notImplemented()
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"init" -> initialize(result)
"shutdown" -> shutdown(result)
"invokeMethod" -> invokeMethod(call, result)
"getRunTime" -> getRunTime(result)
"syncState" -> syncState(call, result)
"start" -> start(result)
"stop" -> stop(result)
else -> result.notImplemented()
}
}
private fun handleInvokeAction(call: MethodCall, result: MethodChannel.Result) {
launch {
val data = call.arguments<String>()!!
Service.invokeAction(data) {
result.success(it)
private fun initialize(result: MethodChannel.Result) {
ServiceController.setEventListener(::sendEvent)
.onSuccess { result.success("") }
.onFailure { error -> result.success(error.message.orEmpty()) }
}
private fun shutdown(result: MethodChannel.Result) {
scope.launch {
ServiceController.unbind()
result.success(true)
}
}
private fun invokeMethod(call: MethodCall, result: MethodChannel.Result) {
val data = call.arguments as? String
if (data == null) {
result.error("INVALID_ARGUMENT", "Method call payload must be a string", null)
return
}
scope.launch {
ServiceController.invokeMethod(data) { response ->
result.success(response)
}.onFailure { error ->
result.error("CORE_ERROR", error.message, null)
}
}
}
private fun handleShutdown(result: MethodChannel.Result) {
Service.unbind()
result.success(true)
}
private fun handleStart(result: MethodChannel.Result) {
State.handleStartService()
result.success(true)
}
private fun handleStop(result: MethodChannel.Result) {
State.handleStopService()
result.success(true)
}
val semaphore = Semaphore(10)
fun handleSendEvent(value: String?) {
launch(Dispatchers.Main) {
semaphore.withPermit {
flutterMethodChannel.invokeMethod("event", value)
}
private fun getRunTime(result: MethodChannel.Result) {
scope.launch {
result.success(ServiceState.refresh())
}
}
private fun onServiceDisconnected(message: String) {
State.runStateFlow.tryEmit(RunState.STOP)
flutterMethodChannel.invokeMethodOnMainThread<Any>("crash", message)
}
private fun handleSyncState(call: MethodCall, result: MethodChannel.Result) {
val data = call.arguments<String>()!!
State.sharedState = Gson().fromJson(data, SharedState::class.java)
launch {
State.syncState()
private fun syncState(call: MethodCall, result: MethodChannel.Result) {
val data = call.arguments as? String
val state = runCatching {
gson.fromJson(data, SharedState::class.java)
}.getOrNull()
if (state == null) {
result.success("Invalid shared state")
return
}
scope.launch {
ServiceState.syncSharedState(state)
result.success("")
}
}
fun handleInit(result: MethodChannel.Result) {
Service.bind()
launch {
Service.setEventListener {
handleSendEvent(it)
}.onSuccess {
result.success("")
}.onFailure {
result.success(it.message)
}
}
Service.onServiceDisconnected = ::onServiceDisconnected
private fun start(result: MethodChannel.Result) {
ServiceState.requestStart()
result.success(true)
}
private fun handleGetRunTime(result: MethodChannel.Result) {
launch {
State.handleSyncState()
result.success(State.runTime)
private fun stop(result: MethodChannel.Result) {
ServiceState.requestStop()
result.success(true)
}
private fun sendEvent(value: String?) {
scope.launch(Dispatchers.Main) {
channel.invokeMethod("event", value)
}
}
}
}
@@ -7,7 +7,6 @@ import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
class TilePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
private lateinit var channel: MethodChannel
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
@@ -21,13 +20,14 @@ class TilePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
}
fun handleStart() {
channel.invokeMethodOnMainThread<Any>("start", null)
channel.invokeMethodOnMainThread("start")
}
fun handleStop() {
channel.invokeMethodOnMainThread<Any>("stop", null)
channel.invokeMethodOnMainThread("stop")
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {}
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
result.notImplemented()
}
}
@@ -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>
-3
View File
@@ -14,9 +14,6 @@ rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
+1 -12
View File
@@ -9,24 +9,13 @@ android {
compileSdk = libs.versions.compileSdk.get().toInt()
defaultConfig {
minSdk = 21
consumerProguardFiles("consumer-rules.pro")
}
buildTypes {
release {
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
minSdk = libs.versions.minSdk.get().toInt()
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
kotlin {
View File
@@ -5,12 +5,12 @@ import android.content.ComponentName
object Components {
const val PACKAGE_NAME = "com.follow.clash"
val MAIN_ACTIVITY =
val mainActivity =
ComponentName(GlobalState.packageName, "${PACKAGE_NAME}.MainActivity")
val TEMP_ACTIVITY =
ComponentName(GlobalState.packageName, "${PACKAGE_NAME}.TempActivity")
val quickActionActivity =
ComponentName(GlobalState.packageName, "${PACKAGE_NAME}.QuickActionActivity")
val BROADCAST_RECEIVER =
ComponentName(GlobalState.packageName, "${PACKAGE_NAME}.BroadcastReceiver")
}
val serviceBroadcastReceiver =
ComponentName(GlobalState.packageName, "${PACKAGE_NAME}.ServiceBroadcastReceiver")
}
@@ -2,7 +2,6 @@ package com.follow.clash.common
import com.google.gson.annotations.SerializedName
enum class QuickAction {
STOP,
START,
@@ -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,42 +39,26 @@ 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 Service.startForeground(notification: Notification) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val manager = getSystemService(NotificationManager::class.java)
@@ -112,13 +66,21 @@ fun Service.startForeground(notification: Notification) {
if (channel == null) {
channel = NotificationChannel(
GlobalState.NOTIFICATION_CHANNEL,
"SERVICE_CHANNEL",
NotificationManager.IMPORTANCE_LOW
getString(R.string.service_channel_name),
NotificationManager.IMPORTANCE_LOW,
)
manager?.createNotificationChannel(channel)
}
}
startForegroundCompat(GlobalState.NOTIFICATION_ID, notification)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(
GlobalState.NOTIFICATION_ID,
notification,
FOREGROUND_SERVICE_TYPE_SPECIAL_USE,
)
} else {
startForeground(GlobalState.NOTIFICATION_ID, notification)
}
}
@SuppressLint("UnspecifiedRegisterReceiverFlag")
@@ -144,107 +106,3 @@ fun Context.receiveBroadcastFlow(
registerReceiverCompat(receiver, filter)
awaitClose { unregisterReceiver(receiver) }
}
inline fun <reified T : IBinder> Context.bindServiceFlow(
intent: Intent,
flags: Int = Context.BIND_AUTO_CREATE,
maxRetries: Int = 10,
retryDelayMillis: Long = 200L
): Flow<Pair<IBinder?, String>> = callbackFlow {
val connection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
if (binder != null) {
try {
@Suppress("UNCHECKED_CAST") val casted = binder as? T
if (casted != null) {
trySend(Pair(casted, ""))
} else {
trySend(Pair(null, "Binder is not of type ${T::class.java}"))
}
} catch (e: RemoteException) {
trySend(Pair(null, "Failed to link to death: ${e.message}"))
}
} else {
trySend(Pair(null, "Binder empty"))
}
}
override fun onServiceDisconnected(name: ComponentName?) {
trySend(Pair(null, "Service disconnected"))
}
}
val success = withContext(Dispatchers.Main) {
bindService(intent, connection, flags)
}
if (!success) {
throw IllegalStateException("bindService() failed, will retry")
}
awaitClose {
Handler(Looper.getMainLooper()).post {
unbindService(connection)
trySend(Pair(null, ""))
}
}
}.retryWhen { cause, attempt ->
if (attempt < maxRetries && cause is Exception) {
delay(retryDelayMillis)
true
} else {
false
}
}
val Long.formatBytes: String
get() {
val units = arrayOf("B", "KB", "MB", "GB", "TB")
var size = this.toDouble()
var unitIndex = 0
while (size >= 1024 && unitIndex < units.size - 1) {
size /= 1024
unitIndex++
}
return if (unitIndex == 0) {
"${size.toLong()}${units[unitIndex]}"
} else {
"%.1f${units[unitIndex]}".format(size)
}
}
fun String.chunkedForAidl(charset: Charset = Charsets.UTF_8): List<ByteArray> {
val allBytes = toByteArray(charset)
val total = allBytes.size
val maxBytes = when {
total <= 100 * 1024 -> total
total <= 1024 * 1024 -> 64 * 1024
total <= 10 * 1024 * 1024 -> 128 * 1024
else -> 256 * 1024
}
val result = mutableListOf<ByteArray>()
var index = 0
while (index < total) {
val end = minOf(index + maxBytes, total)
result.add(allBytes.copyOfRange(index, end))
index = end
}
return result
}
fun <T : List<ByteArray>> T.formatString(charset: Charset = Charsets.UTF_8): String {
val totalSize = this.sumOf { it.size }
val combined = ByteArray(totalSize)
var offset = 0
forEach { byteArray ->
byteArray.copyInto(combined, offset)
offset += byteArray.size
}
return String(combined, charset)
}
@@ -1,47 +1,47 @@
package com.follow.clash.common
import android.app.Application
import android.util.Log
import com.google.firebase.FirebaseApp
import com.google.firebase.crashlytics.FirebaseCrashlytics
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
object GlobalState : CoroutineScope by CoroutineScope(Dispatchers.Default) {
object GlobalState : CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) {
const val NOTIFICATION_CHANNEL = "FlClash"
const val NOTIFICATION_ID = 1
val packageName: String
get() = application.packageName
val RECEIVE_BROADCASTS_PERMISSIONS: String
get() = "${packageName}.permission.RECEIVE_BROADCASTS"
private var _application: Application? = null
val receiveBroadcastPermission: String
get() = "$packageName.permission.RECEIVE_BROADCASTS"
val application: Application
get() = _application!!
get() = checkNotNull(appInstance) { "GlobalState is not initialized" }
fun log(text: String) {
Log.d("[FlClash]", text)
}
@Volatile
private var appInstance: Application? = null
fun init(application: Application) {
_application = application
appInstance = application
}
fun log(text: String) {
Log.d("FlClash", text)
}
fun setCrashlytics(enable: Boolean) {
_application?.let {
FirebaseApp.initializeApp(it)
FirebaseCrashlytics.getInstance().isCrashlyticsCollectionEnabled = enable
if (enable) {
log("init crashlytics ${it.processName}")
}
FirebaseApp.initializeApp(application)
FirebaseCrashlytics.getInstance().isCrashlyticsCollectionEnabled = enable
if (enable) {
log("Crashlytics enabled")
}
}
}
fun didCrashOnPreviousExecution(): Boolean {
FirebaseApp.initializeApp(application)
return FirebaseCrashlytics.getInstance().didCrashOnPreviousExecution()
}
}
@@ -1,78 +0,0 @@
package com.follow.clash.common
import android.content.Intent
import android.os.IBinder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import java.util.concurrent.atomic.AtomicBoolean
class ServiceDelegate<T>(
private val intent: Intent,
private val onServiceDisconnected: ((String) -> Unit)? = null,
private val interfaceCreator: (IBinder) -> T,
) : CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) {
private val _bindingState = AtomicBoolean(false)
private var _serviceState = MutableStateFlow<Pair<T?, String>?>(null)
val serviceState: StateFlow<Pair<T?, String>?> = _serviceState
private var job: Job? = null
private fun handleBind(data: Pair<IBinder?, String>) {
data.first?.let {
_serviceState.value = Pair(interfaceCreator(it), data.second)
} ?: run {
_serviceState.value = Pair(null, data.second)
unbind()
onServiceDisconnected?.invoke(data.second)
_bindingState.set(false)
}
}
fun bind() {
if (_bindingState.compareAndSet(false, true)) {
job?.cancel()
job = null
_serviceState.value = null
job = launch {
runCatching {
GlobalState.application.bindServiceFlow<IBinder>(intent)
.collect { handleBind(it) }
}
}
}
}
suspend inline fun <R> useService(
timeoutMillis: Long = 5000, crossinline block: suspend (T) -> R
): Result<R> {
return runCatching {
withTimeout(timeoutMillis) {
val state = serviceState.filterNotNull().first()
state.first?.let {
withContext(Dispatchers.Default) {
block(it)
}
} ?: throw Exception(state.second)
}
}
}
fun unbind() {
if (_bindingState.compareAndSet(true, false)) {
job?.cancel()
job = null
_serviceState.value = null
}
}
}
@@ -1,14 +0,0 @@
package com.follow.clash.common
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
fun tickerFlow(delayMillis: Long, initialDelayMillis: Long = delayMillis): Flow<Unit> = flow {
delay(initialDelayMillis)
while (true) {
emit(Unit)
delay(delayMillis)
}
}
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="FlClash">FlClash</string>
<string name="app_name">FlClash</string>
<string name="service_channel_name">FlClash Service</string>
</resources>
-16
View File
@@ -13,13 +13,6 @@ android {
minSdk = libs.versions.minSdk.get().toInt()
}
sourceSets {
getByName("main") {
jniLibs.srcDirs("src/main/jniLibs")
}
}
externalNativeBuild {
cmake {
path("src/main/cpp/CMakeLists.txt")
@@ -32,14 +25,6 @@ android {
targetCompatibility = JavaVersion.VERSION_17
}
buildTypes {
release {
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
}
kotlin {
@@ -48,7 +33,6 @@ kotlin {
}
}
dependencies {
implementation(libs.annotation.jvm)
}
+25 -11
View File
@@ -34,9 +34,9 @@ Java_com_follow_clash_core_Core_updateDNS(JNIEnv *env, jobject thiz, jstring dns
extern "C"
JNIEXPORT void JNICALL
Java_com_follow_clash_core_Core_invokeAction(JNIEnv *env, jobject thiz, jstring data, jobject cb) {
Java_com_follow_clash_core_Core_invokeMethod(JNIEnv *env, jobject thiz, jstring data, jobject cb) {
const auto interface = new_global(cb);
invokeAction(interface, get_string(data));
invokeMethod(interface, get_string(data));
}
extern "C"
@@ -54,14 +54,16 @@ extern "C"
JNIEXPORT jstring JNICALL
Java_com_follow_clash_core_Core_getTraffic(JNIEnv *env, jobject thiz,
const jboolean only_statistics_proxy) {
return new_string(getTraffic(only_statistics_proxy));
scoped_string traffic = getTraffic(only_statistics_proxy);
return new_string(traffic);
}
extern "C"
JNIEXPORT jstring JNICALL
Java_com_follow_clash_core_Core_getTotalTraffic(JNIEnv *env, jobject thiz,
const jboolean only_statistics_proxy) {
return new_string(getTotalTraffic(only_statistics_proxy));
scoped_string traffic = getTotalTraffic(only_statistics_proxy);
return new_string(traffic);
}
extern "C"
@@ -106,21 +108,31 @@ call_tun_interface_resolve_process_impl(void *tun_interface, const int protocol,
const char *target,
const int uid) {
ATTACH_JNI();
const auto packageName = reinterpret_cast<jstring>(env->CallObjectMethod(
const auto source_string = new_string(source);
const auto target_string = new_string(target);
const auto package_name = reinterpret_cast<jstring>(env->CallObjectMethod(
static_cast<jobject>(tun_interface),
m_tun_interface_resolve_process,
protocol,
new_string(source),
new_string(target),
source_string,
target_string,
uid));
return get_string(packageName);
env->DeleteLocalRef(source_string);
env->DeleteLocalRef(target_string);
const auto result = get_string(package_name);
if (package_name != nullptr) {
env->DeleteLocalRef(package_name);
}
return result;
}
static void call_invoke_interface_result_impl(void *invoke_interface, const char *data) {
ATTACH_JNI();
const auto value = new_string(data);
env->CallVoidMethod(static_cast<jobject>(invoke_interface),
m_invoke_interface_result,
new_string(data));
value);
env->DeleteLocalRef(value);
}
extern "C"
@@ -166,7 +178,7 @@ Java_com_follow_clash_core_Core_stopTun(JNIEnv *env, jobject thiz) {
extern "C"
JNIEXPORT void JNICALL
Java_com_follow_clash_core_Core_invokeAction(JNIEnv *env, jobject thiz, jstring data, jobject cb) {
Java_com_follow_clash_core_Core_invokeMethod(JNIEnv *env, jobject thiz, jstring data, jobject cb) {
}
extern "C"
@@ -188,11 +200,13 @@ extern "C"
JNIEXPORT jstring JNICALL
Java_com_follow_clash_core_Core_getTraffic(JNIEnv *env, jobject thiz,
const jboolean only_statistics_proxy) {
return env->NewStringUTF("{}");
}
extern "C"
JNIEXPORT jstring JNICALL
Java_com_follow_clash_core_Core_getTotalTraffic(JNIEnv *env, jobject thiz,
const jboolean only_statistics_proxy) {
return env->NewStringUTF("{}");
}
extern "C"
@@ -205,4 +219,4 @@ JNIEXPORT void JNICALL
Java_com_follow_clash_core_Core_quickSetup(JNIEnv *env, jobject thiz, jstring init_params_string,
jstring setup_params_string, jobject cb) {
}
#endif
#endif
+12 -22
View File
@@ -1,7 +1,6 @@
#include "jni_helper.h"
#include <cstdlib>
#include <malloc.h>
#include <cstring>
static JavaVM *global_vm;
@@ -18,15 +17,15 @@ void initialize_jni(JavaVM *vm, JNIEnv *env) {
m_get_bytes = find_method(c_string, "getBytes", "()[B");
}
JavaVM *global_java_vm() {
return global_vm;
}
char *jni_get_string(JNIEnv *env, jstring str) {
if (str == nullptr) {
return static_cast<char *>(calloc(1, 1));
}
const auto array = reinterpret_cast<jbyteArray>(env->CallObjectMethod(str, m_get_bytes));
const int length = env->GetArrayLength(array);
const auto content = static_cast<char *>(malloc(length + 1));
env->GetByteArrayRegion(array, 0, length, reinterpret_cast<jbyte *>(content));
env->DeleteLocalRef(array);
content[length] = 0;
return content;
}
@@ -35,37 +34,28 @@ jstring jni_new_string(JNIEnv *env, const char *str) {
const auto length = static_cast<int>(strlen(str));
const auto array = env->NewByteArray(length);
env->SetByteArrayRegion(array, 0, length, reinterpret_cast<const jbyte *>(str));
return reinterpret_cast<jstring>(env->NewObject(c_string, m_new_string, array));
}
int jni_catch_exception(JNIEnv *env) {
const int result = env->ExceptionCheck();
if (result) {
env->ExceptionDescribe();
env->ExceptionClear();
}
const auto result = reinterpret_cast<jstring>(env->NewObject(c_string, m_new_string, array));
env->DeleteLocalRef(array);
return result;
}
void jni_attach_thread(scoped_jni *jni) {
JavaVM *vm = global_java_vm();
if (vm->GetEnv(reinterpret_cast<void **>(&jni->env), JNI_VERSION_1_6) == JNI_OK) {
if (global_vm->GetEnv(reinterpret_cast<void **>(&jni->env), JNI_VERSION_1_6) == JNI_OK) {
jni->require_release = 0;
return;
}
if (vm->AttachCurrentThread(&jni->env, nullptr) != JNI_OK) {
if (global_vm->AttachCurrentThread(&jni->env, nullptr) != JNI_OK) {
abort();
}
jni->require_release = 1;
}
void jni_detach_thread(const scoped_jni *env) {
JavaVM *vm = global_java_vm();
if (env->require_release) {
vm->DetachCurrentThread();
global_vm->DetachCurrentThread();
}
}
void release_string(char **str) {
free(*str);
}
void release_string(char **value) {
free(*value);
}
+1 -3
View File
@@ -13,13 +13,11 @@ extern jstring jni_new_string(JNIEnv *env, const char *str);
extern char *jni_get_string(JNIEnv *env, jstring str);
extern int jni_catch_exception(JNIEnv *env);
extern void jni_attach_thread(scoped_jni *jni);
extern void jni_detach_thread(const scoped_jni *env);
extern void release_string( char **str);
extern void release_string(char **value);
#define ATTACH_JNI() __attribute__((unused, cleanup(jni_detach_thread))) \
scoped_jni _jni{}; \
@@ -2,9 +2,9 @@ package com.follow.clash.core
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.URL
import java.net.URI
data object Core {
object Core {
private external fun startTun(
fd: Int,
cb: TunInterface,
@@ -13,17 +13,17 @@ data object Core {
dns: String,
)
external fun forceGC(
)
external fun forceGC()
external fun updateDNS(
dns: String,
)
private fun parseInetSocketAddress(address: String): InetSocketAddress {
val url = URL("https://$address")
return InetSocketAddress(InetAddress.getByName(url.host), url.port)
val uri = URI("tcp://$address")
val host = requireNotNull(uri.host) { "Missing host in address: $address" }
require(uri.port >= 0) { "Missing port in address: $address" }
return InetSocketAddress(InetAddress.getByName(host), uri.port)
}
fun startTun(
@@ -45,7 +45,7 @@ data object Core {
protocol: Int,
source: String,
target: String,
uid: Int
uid: Int,
): String {
return resolverProcess(
protocol,
@@ -57,7 +57,7 @@ data object Core {
},
stack,
address,
dns
dns,
)
}
@@ -65,16 +65,16 @@ data object Core {
suspended: Boolean,
)
private external fun invokeAction(
private external fun invokeMethod(
data: String,
cb: InvokeInterface
cb: InvokeInterface,
)
fun invokeAction(
fun invokeMethod(
data: String,
cb: (result: String?) -> Unit
cb: (result: String?) -> Unit,
) {
invokeAction(
invokeMethod(
data,
object : InvokeInterface {
override fun onResult(result: String?) {
@@ -86,33 +86,33 @@ data object Core {
private external fun setEventListener(cb: InvokeInterface?)
fun callSetEventListener(
cb: ((result: String?) -> Unit)?
fun updateEventListener(
callback: ((result: String?) -> Unit)?,
) {
when (cb != null) {
true -> setEventListener(
if (callback == null) {
setEventListener(null)
} else {
setEventListener(
object : InvokeInterface {
override fun onResult(result: String?) {
cb(result)
callback(result)
}
},
)
false -> setEventListener(null)
}
}
fun quickSetup(
initParamsString: String,
setupParamsString: String,
cb: (result: String?) -> Unit,
callback: (result: String?) -> Unit,
) {
quickSetup(
initParamsString,
setupParamsString,
object : InvokeInterface {
override fun onResult(result: String?) {
cb(result)
callback(result)
}
},
)
@@ -121,7 +121,7 @@ data object Core {
private external fun quickSetup(
initParamsString: String,
setupParamsString: String,
cb: InvokeInterface
cb: InvokeInterface,
)
external fun stopTun()
@@ -133,4 +133,4 @@ data object Core {
init {
System.loadLibrary("core")
}
}
}
@@ -5,5 +5,6 @@ import androidx.annotation.Keep
@Keep
interface TunInterface {
fun protect(fd: Int)
fun resolverProcess(protocol: Int, source: String, target: String, uid: Int): String
}
}
-1
View File
@@ -1,6 +1,5 @@
org.gradle.jvmargs=-Xmx4G
android.useAndroidX=true
android.enableJetifier=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
-6
View File
@@ -1,5 +1,4 @@
[versions]
#agp = "8.10.1"
firebaseBom = "34.15.0"
minSdk = "23"
targetSdk = "36"
@@ -9,10 +8,7 @@ coreKtx = "1.17.0"
annotationJvm = "1.9.1"
coreSplashscreen = "1.0.1"
gson = "2.13.1"
kotlin = "2.2.20"
smaliDexlib2 = "3.0.9"
firebaseCrashlyticsKtx = "20.0.6"
firebaseCommonKtx = "22.1.0"
[libraries]
androidx-core = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
@@ -23,5 +19,3 @@ firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "fir
firebase-crashlytics-ndk = { module = "com.google.firebase:firebase-crashlytics-ndk" }
gson = { module = "com.google.code.gson:gson", version.ref = "gson" }
smali-dexlib2 = { module = "com.android.tools.smali:smali-dexlib2", version.ref = "smaliDexlib2" }
firebase-crashlytics-ktx = { group = "com.google.firebase", name = "firebase-crashlytics-ktx", version.ref = "firebaseCrashlyticsKtx" }
firebase-common-ktx = { group = "com.google.firebase", name = "firebase-common-ktx", version.ref = "firebaseCommonKtx" }
+1 -14
View File
@@ -6,29 +6,17 @@ plugins {
android {
namespace = "com.follow.clash.service"
compileSdk = 36
compileSdk = libs.versions.compileSdk.get().toInt()
defaultConfig {
minSdk = libs.versions.minSdk.get().toInt()
}
buildFeatures {
aidl = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildTypes {
release {
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
}
kotlin {
@@ -37,7 +25,6 @@ kotlin {
}
}
dependencies {
implementation(project(":core"))
implementation(project(":common"))
+5 -14
View File
@@ -8,8 +8,7 @@
android:name=".VpnService"
android:exported="false"
android:foregroundServiceType="specialUse"
android:permission="android.permission.BIND_VPN_SERVICE"
android:process=":remote">
android:permission="android.permission.BIND_VPN_SERVICE">
<intent-filter>
<action android:name="android.net.VpnService" />
</intent-filter>
@@ -19,31 +18,23 @@
</service>
<service
android:name=".CommonService"
android:name=".ProxyService"
android:exported="false"
android:foregroundServiceType="specialUse"
android:process=":remote">
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="proxy" />
</service>
<service
android:name=".RemoteService"
android:enabled="true"
android:exported="false"
android:process=":remote" />
<provider
android:name=".FilesProvider"
android:authorities="${applicationId}.files"
android:exported="true"
android:grantUriPermissions="true"
android:permission="android.permission.MANAGE_DOCUMENTS"
android:process=":remote">
android:permission="android.permission.MANAGE_DOCUMENTS">
<intent-filter>
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
</intent-filter>
</provider>
</application>
</manifest>
</manifest>
@@ -1,8 +0,0 @@
// IAckInterface.aidl
package com.follow.clash.service;
import com.follow.clash.service.IAckInterface;
interface IAckInterface {
oneway void onAck();
}
@@ -1,8 +0,0 @@
// ICallbackInterface.aidl
package com.follow.clash.service;
import com.follow.clash.service.IAckInterface;
interface ICallbackInterface {
oneway void onResult(in byte[] data,in boolean isSuccess, in IAckInterface ack);
}
@@ -1,8 +0,0 @@
// IEventInterface.aidl
package com.follow.clash.service;
import com.follow.clash.service.IAckInterface;
interface IEventInterface {
oneway void onEvent(in String id, in byte[] data,in boolean isSuccess, in IAckInterface ack);
}
@@ -1,20 +0,0 @@
// IRemoteInterface.aidl
package com.follow.clash.service;
import com.follow.clash.service.ICallbackInterface;
import com.follow.clash.service.IEventInterface;
import com.follow.clash.service.IResultInterface;
import com.follow.clash.service.IVoidInterface;
import com.follow.clash.service.models.VpnOptions;
import com.follow.clash.service.models.NotificationParams;
interface IRemoteInterface {
void invokeAction(in String data, in ICallbackInterface callback);
void quickSetup(in String initParamsString, in String setupParamsString, in ICallbackInterface callback, in IVoidInterface onStarted);
void updateNotificationParams(in NotificationParams params);
void startService(in VpnOptions options, in long runTime, in IResultInterface result);
void stopService(in IResultInterface result);
void setEventListener(in IEventInterface event);
void setCrashlytics(in boolean enable);
long getRunTime();
}
@@ -1,6 +0,0 @@
// IResultInterface.aidl
package com.follow.clash.service;
interface IResultInterface {
oneway void onResult(in long runTime);
}
@@ -1,6 +0,0 @@
// IVoidInterface.aidl
package com.follow.clash.service;
interface IVoidInterface {
oneway void invoke();
}
@@ -1,4 +0,0 @@
//AccessControl.aidl
package com.follow.clash.service.models;
parcelable AccessControl;
@@ -1,4 +0,0 @@
//NotificationParams.aidl
package com.follow.clash.service.models;
parcelable NotificationParams;
@@ -1,6 +0,0 @@
//VpnOptions.aidl
package com.follow.clash.service.models;
import com.follow.clash.service.models.AccessControl;
parcelable VpnOptions;
@@ -1,64 +0,0 @@
package com.follow.clash.service
import android.app.Service
import android.content.Intent
import android.os.Binder
import android.os.IBinder
import com.follow.clash.core.Core
import com.follow.clash.service.modules.NetworkObserveModule
import com.follow.clash.service.modules.NotificationModule
import com.follow.clash.service.modules.SuspendModule
import com.follow.clash.service.modules.moduleLoader
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
class CommonService : Service(), IBaseService,
CoroutineScope by CoroutineScope(Dispatchers.Default) {
private val self: CommonService
get() = this
private val loader = moduleLoader {
install(NetworkObserveModule(self))
install(NotificationModule(self))
install(SuspendModule(self))
}
override fun onCreate() {
super.onCreate()
handleCreate()
}
override fun onDestroy() {
handleDestroy()
super.onDestroy()
}
override fun onLowMemory() {
Core.forceGC()
super.onLowMemory()
}
private val binder = LocalBinder()
inner class LocalBinder : Binder() {
fun getService(): CommonService = this@CommonService
}
override fun onBind(intent: Intent): IBinder {
return binder
}
override fun start() {
try {
loader.load()
} catch (_: Exception) {
stop()
}
}
override fun stop() {
loader.cancel()
stopSelf()
}
}
@@ -6,60 +6,34 @@ import android.os.CancellationSignal
import android.os.ParcelFileDescriptor
import android.provider.DocumentsContract
import android.provider.DocumentsProvider
import com.follow.clash.common.R as CommonR
import java.io.File
import java.io.FileNotFoundException
import java.io.IOException
class FilesProvider : DocumentsProvider() {
override fun onCreate() = true
companion object {
private const val DEFAULT_ROOT_ID = "0"
private val DEFAULT_DOCUMENT_COLUMNS = arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_MIME_TYPE,
DocumentsContract.Document.COLUMN_FLAGS,
DocumentsContract.Document.COLUMN_SIZE,
)
private val DEFAULT_ROOT_COLUMNS = arrayOf(
DocumentsContract.Root.COLUMN_ROOT_ID,
DocumentsContract.Root.COLUMN_FLAGS,
DocumentsContract.Root.COLUMN_ICON,
DocumentsContract.Root.COLUMN_TITLE,
DocumentsContract.Root.COLUMN_SUMMARY,
DocumentsContract.Root.COLUMN_DOCUMENT_ID
)
}
override fun onCreate(): Boolean {
return true
}
override fun queryRoots(projection: Array<String>?): Cursor {
return MatrixCursor(projection ?: DEFAULT_ROOT_COLUMNS).apply {
newRow().apply {
add(DocumentsContract.Root.COLUMN_ROOT_ID, DEFAULT_ROOT_ID)
add(DocumentsContract.Root.COLUMN_FLAGS, DocumentsContract.Root.FLAG_LOCAL_ONLY)
add(DocumentsContract.Root.COLUMN_ICON, R.drawable.ic_service)
add(DocumentsContract.Root.COLUMN_TITLE, "FlClash")
add(DocumentsContract.Root.COLUMN_SUMMARY, "Data")
add(DocumentsContract.Root.COLUMN_DOCUMENT_ID, "/")
}
override fun queryRoots(projection: Array<String>?): Cursor =
MatrixCursor(projection ?: DEFAULT_ROOT_COLUMNS).apply {
newRow()
.add(DocumentsContract.Root.COLUMN_ROOT_ID, DEFAULT_ROOT_ID)
.add(DocumentsContract.Root.COLUMN_FLAGS, DocumentsContract.Root.FLAG_LOCAL_ONLY)
.add(DocumentsContract.Root.COLUMN_ICON, R.drawable.ic_service)
.add(
DocumentsContract.Root.COLUMN_TITLE,
context?.getString(CommonR.string.app_name).orEmpty(),
)
.add(DocumentsContract.Root.COLUMN_DOCUMENT_ID, ROOT_DOCUMENT_ID)
}
}
override fun queryChildDocuments(
parentDocumentId: String,
projection: Array<String>?,
sortOrder: String?
sortOrder: String?,
): Cursor {
val result = MatrixCursor(resolveDocumentProjection(projection))
val parentFile = if (parentDocumentId == "/") {
context?.filesDir
} else {
File(parentDocumentId)
} ?: throw FileNotFoundException("Parent directory not found")
val result = MatrixCursor(projection ?: DEFAULT_DOCUMENT_COLUMNS)
val parentFile = resolveFile(parentDocumentId)
parentFile.listFiles()?.forEach { file ->
includeFile(result, file)
}
@@ -67,20 +41,18 @@ class FilesProvider : DocumentsProvider() {
}
override fun queryDocument(documentId: String, projection: Array<String>?): Cursor {
val result = MatrixCursor(resolveDocumentProjection(projection))
val file = File(documentId)
includeFile(result, file)
val result = MatrixCursor(projection ?: DEFAULT_DOCUMENT_COLUMNS)
includeFile(result, resolveFile(documentId))
return result
}
override fun openDocument(
documentId: String,
mode: String,
signal: CancellationSignal?
signal: CancellationSignal?,
): ParcelFileDescriptor {
val file = File(documentId)
val accessMode = ParcelFileDescriptor.parseMode(mode)
return ParcelFileDescriptor.open(file, accessMode)
return ParcelFileDescriptor.open(resolveFile(documentId), accessMode)
}
private fun includeFile(result: MatrixCursor, file: File) {
@@ -88,23 +60,54 @@ class FilesProvider : DocumentsProvider() {
add(DocumentsContract.Document.COLUMN_DOCUMENT_ID, file.absolutePath)
add(DocumentsContract.Document.COLUMN_DISPLAY_NAME, file.name)
add(DocumentsContract.Document.COLUMN_SIZE, file.length())
add(
DocumentsContract.Document.COLUMN_FLAGS,
DocumentsContract.Document.FLAG_SUPPORTS_WRITE or DocumentsContract.Document.FLAG_SUPPORTS_DELETE
)
add(DocumentsContract.Document.COLUMN_MIME_TYPE, getDocumentType(file))
val flags = if (file.isFile) {
DocumentsContract.Document.FLAG_SUPPORTS_WRITE
} else {
0
}
val mimeType = if (file.isDirectory) {
DocumentsContract.Document.MIME_TYPE_DIR
} else {
"application/octet-stream"
}
add(DocumentsContract.Document.COLUMN_FLAGS, flags)
add(DocumentsContract.Document.COLUMN_MIME_TYPE, mimeType)
}
}
private fun getDocumentType(file: File): String {
return if (file.isDirectory) {
DocumentsContract.Document.MIME_TYPE_DIR
} else {
"application/octet-stream"
private fun resolveFile(documentId: String): File {
val root = context?.filesDir?.canonicalFile
?: throw FileNotFoundException("App files directory is unavailable")
val file = try {
if (documentId == ROOT_DOCUMENT_ID) root else File(documentId).canonicalFile
} catch (error: IOException) {
throw FileNotFoundException(error.message).apply { initCause(error) }
}
val isInsideRoot = file == root || file.path.startsWith("${root.path}${File.separator}")
if (!isInsideRoot) {
throw FileNotFoundException("Document is outside the app files directory")
}
return file
}
private fun resolveDocumentProjection(projection: Array<String>?): Array<String> {
return projection ?: DEFAULT_DOCUMENT_COLUMNS
private companion object {
const val DEFAULT_ROOT_ID = "0"
const val ROOT_DOCUMENT_ID = "/"
val DEFAULT_DOCUMENT_COLUMNS = arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_MIME_TYPE,
DocumentsContract.Document.COLUMN_FLAGS,
DocumentsContract.Document.COLUMN_SIZE,
)
val DEFAULT_ROOT_COLUMNS = arrayOf(
DocumentsContract.Root.COLUMN_ROOT_ID,
DocumentsContract.Root.COLUMN_FLAGS,
DocumentsContract.Root.COLUMN_ICON,
DocumentsContract.Root.COLUMN_TITLE,
DocumentsContract.Root.COLUMN_SUMMARY,
DocumentsContract.Root.COLUMN_DOCUMENT_ID,
)
}
}
}
@@ -1,21 +0,0 @@
package com.follow.clash.service
import com.follow.clash.common.BroadcastAction
import com.follow.clash.common.GlobalState
import com.follow.clash.common.sendBroadcast
interface IBaseService {
fun handleCreate() {
GlobalState.log("Service create")
BroadcastAction.SERVICE_CREATED.sendBroadcast()
}
fun handleDestroy() {
GlobalState.log("Service destroy")
BroadcastAction.SERVICE_DESTROYED.sendBroadcast()
}
fun start()
fun stop()
}
@@ -0,0 +1,22 @@
package com.follow.clash.service
import android.app.Service
import com.follow.clash.common.BroadcastAction
import com.follow.clash.common.GlobalState
import com.follow.clash.common.sendBroadcast
interface ManagedService {
fun start()
fun stop()
}
internal fun Service.notifyVpnStartRequested() {
GlobalState.log("VPN start requested")
BroadcastAction.VPN_START_REQUESTED.sendBroadcast()
}
internal fun Service.notifyVpnRevoked() {
GlobalState.log("VPN permission revoked")
BroadcastAction.VPN_REVOKED.sendBroadcast()
}
@@ -0,0 +1,52 @@
package com.follow.clash.service
import android.app.Service
import android.content.Intent
import android.os.Binder
import android.os.IBinder
import com.follow.clash.core.Core
import com.follow.clash.service.modules.ServiceModules
class ProxyService : Service(), ManagedService {
private val modules = ServiceModules(this)
private val binder = LocalBinder()
override fun onDestroy() {
try {
cleanup()
} finally {
super.onDestroy()
}
}
override fun onLowMemory() {
Core.forceGC()
super.onLowMemory()
}
inner class LocalBinder : Binder() {
val service: ProxyService
get() = this@ProxyService
}
override fun onBind(intent: Intent): IBinder = binder
override fun start() {
try {
modules.start()
} catch (error: Exception) {
stop()
throw error
}
}
override fun stop() {
try {
cleanup()
} finally {
stopSelf()
}
}
private fun cleanup() = modules.stop()
}
@@ -1,196 +0,0 @@
package com.follow.clash.service
import android.app.Service
import android.content.Intent
import android.os.IBinder
import com.follow.clash.common.GlobalState
import com.follow.clash.common.ServiceDelegate
import com.follow.clash.common.chunkedForAidl
import com.follow.clash.common.intent
import com.follow.clash.core.Core
import com.follow.clash.service.State.delegate
import com.follow.clash.service.State.intent
import com.follow.clash.service.State.runLock
import com.follow.clash.service.models.NotificationParams
import com.follow.clash.service.models.VpnOptions
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.withLock
import java.util.UUID
import kotlin.coroutines.resume
class RemoteService : Service(),
CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) {
private fun handleStopService(result: IResultInterface) {
launch {
runLock.withLock {
delegate?.useService { service ->
service.stop()
delegate?.unbind()
}
State.runTime = 0
result.onResult(0)
}
}
}
private fun handleServiceDisconnected(message: String) {
GlobalState.log("Background service disconnected: $message")
intent = null
delegate = null
}
private fun handleStartService(runTime: Long, result: IResultInterface) {
launch {
runLock.withLock {
val nextIntent = when (State.options?.enable == true) {
true -> VpnService::class.intent
false -> CommonService::class.intent
}
if (intent != nextIntent) {
delegate?.unbind()
delegate = ServiceDelegate(nextIntent, ::handleServiceDisconnected) { binder ->
when (binder) {
is VpnService.LocalBinder -> binder.getService()
is CommonService.LocalBinder -> binder.getService()
else -> throw IllegalArgumentException("Invalid binder type")
}
}
intent = nextIntent
delegate?.bind()
}
delegate?.useService { service ->
service.start()
}
State.runTime = when (runTime != 0L) {
true -> runTime
false -> System.currentTimeMillis()
}
result.onResult(State.runTime)
}
}
}
private val binder = object : IRemoteInterface.Stub() {
override fun invokeAction(data: String, callback: ICallbackInterface) {
Core.invokeAction(data) {
launch {
runCatching {
val chunks = it?.chunkedForAidl() ?: listOf()
for ((index, chunk) in chunks.withIndex()) {
suspendCancellableCoroutine { cont ->
callback.onResult(
chunk,
index == chunks.lastIndex,
object : IAckInterface.Stub() {
override fun onAck() {
cont.resume(Unit)
}
},
)
}
}
}
}
}
}
override fun quickSetup(
initParamsString: String,
setupParamsString: String,
callback: ICallbackInterface,
onStarted: IVoidInterface
) {
Core.quickSetup(initParamsString, setupParamsString) {
launch {
runCatching {
val chunks = it?.chunkedForAidl() ?: listOf()
for ((index, chunk) in chunks.withIndex()) {
suspendCancellableCoroutine { cont ->
callback.onResult(
chunk,
index == chunks.lastIndex,
object : IAckInterface.Stub() {
override fun onAck() {
cont.resume(Unit)
}
},
)
}
}
}
}
}
onStarted()
}
override fun updateNotificationParams(params: NotificationParams?) {
State.notificationParamsFlow.tryEmit(params)
}
override fun startService(
options: VpnOptions,
runtime: Long,
result: IResultInterface,
) {
GlobalState.log("remote startService")
State.options = options
handleStartService(runtime, result)
}
override fun stopService(result: IResultInterface) {
handleStopService(result)
}
override fun setEventListener(eventListener: IEventInterface?) {
GlobalState.log("RemoveEventListener ${eventListener == null}")
when (eventListener != null) {
true -> Core.callSetEventListener {
launch {
runCatching {
val id = UUID.randomUUID().toString()
val chunks = it?.chunkedForAidl() ?: listOf()
for ((index, chunk) in chunks.withIndex()) {
suspendCancellableCoroutine { cont ->
eventListener.onEvent(
id,
chunk,
index == chunks.lastIndex,
object : IAckInterface.Stub() {
override fun onAck() {
cont.resume(Unit)
}
},
)
}
}
}
}
}
false -> Core.callSetEventListener(null)
}
}
override fun setCrashlytics(enable: Boolean) {
GlobalState.setCrashlytics(enable)
}
override fun getRunTime(): Long {
return State.runTime
}
}
override fun onBind(intent: Intent?): IBinder {
return binder
}
override fun onDestroy() {
GlobalState.log("Remote service destroy")
super.onDestroy()
}
}
@@ -0,0 +1,25 @@
package com.follow.clash.service
import com.follow.clash.service.models.NotificationParams
import com.follow.clash.service.models.VpnOptions
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
object ServiceConfig {
@Volatile
private var currentVpnOptions: VpnOptions? = null
private val mutableNotificationParams = MutableStateFlow(NotificationParams())
val vpnOptions: VpnOptions?
get() = currentVpnOptions
val notificationParams = mutableNotificationParams.asStateFlow()
fun updateVpnOptions(options: VpnOptions) {
currentVpnOptions = options
}
fun updateNotificationParams(params: NotificationParams) {
mutableNotificationParams.value = params
}
}
@@ -1,22 +0,0 @@
package com.follow.clash.service
import android.content.Intent
import com.follow.clash.common.ServiceDelegate
import com.follow.clash.service.models.NotificationParams
import com.follow.clash.service.models.VpnOptions
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.sync.Mutex
object State {
var options: VpnOptions? = null
var notificationParamsFlow: MutableStateFlow<NotificationParams?> = MutableStateFlow(
NotificationParams()
)
val runLock = Mutex()
var runTime: Long = 0L
var delegate: ServiceDelegate<IBaseService>? = null
var intent: Intent? = null
}
@@ -6,52 +6,40 @@ import android.net.ProxyInfo
import android.os.Binder
import android.os.Build
import android.os.IBinder
import android.os.Parcel
import android.os.RemoteException
import android.util.Log
import androidx.core.content.getSystemService
import com.follow.clash.common.AccessControlMode
import com.follow.clash.common.GlobalState
import com.follow.clash.common.R as CommonR
import com.follow.clash.core.Core
import com.follow.clash.service.models.CIDR
import com.follow.clash.service.models.VpnOptions
import com.follow.clash.service.models.getIpv4RouteAddress
import com.follow.clash.service.models.getIpv6RouteAddress
import com.follow.clash.service.models.toCIDR
import com.follow.clash.service.modules.NetworkObserveModule
import com.follow.clash.service.modules.NotificationModule
import com.follow.clash.service.modules.SuspendModule
import com.follow.clash.service.modules.moduleLoader
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import com.follow.clash.service.modules.ServiceModules
import java.net.InetSocketAddress
import java.util.concurrent.ConcurrentHashMap
import android.net.VpnService as SystemVpnService
class VpnService : SystemVpnService(), IBaseService,
CoroutineScope by CoroutineScope(Dispatchers.Default) {
private val self: VpnService
get() = this
private val loader = moduleLoader {
install(NetworkObserveModule(self))
install(NotificationModule(self))
install(SuspendModule(self))
}
override fun onCreate() {
super.onCreate()
handleCreate()
}
class VpnService : SystemVpnService(), ManagedService {
private val modules = ServiceModules(this)
private val binder = LocalBinder()
private val tunLock = Any()
private var tunRunning = false
override fun onDestroy() {
handleDestroy()
super.onDestroy()
try {
cleanup()
} finally {
super.onDestroy()
}
}
private val connectivity by lazy {
getSystemService<ConnectivityManager>()
}
private val uidPageNameMap = mutableMapOf<Int, String>()
private val uidPackageNameMap = ConcurrentHashMap<Int, String>()
private fun resolverProcess(
protocol: Int,
@@ -67,13 +55,15 @@ class VpnService : SystemVpnService(), IBaseService,
if (nextUid == -1) {
return ""
}
if (!uidPageNameMap.containsKey(nextUid)) {
uidPageNameMap[nextUid] = this.packageManager?.getPackagesForUid(nextUid)?.first() ?: ""
return uidPackageNameMap.getOrPut(nextUid) {
packageManager
.getPackagesForUid(nextUid)
?.firstOrNull()
.orEmpty()
}
return uidPageNameMap[nextUid] ?: ""
}
val VpnOptions.address
private val VpnOptions.tunAddress
get(): String = buildString {
append(IPV4_ADDRESS)
if (ipv6) {
@@ -82,7 +72,7 @@ class VpnService : SystemVpnService(), IBaseService,
}
}
val VpnOptions.dns
private val VpnOptions.tunDns
get(): String {
if (dnsHijacking) {
return NET_ANY
@@ -96,158 +86,180 @@ class VpnService : SystemVpnService(), IBaseService,
}
}
override fun onLowMemory() {
Core.forceGC()
super.onLowMemory()
}
private val binder = LocalBinder()
inner class LocalBinder : Binder() {
fun getService(): VpnService = this@VpnService
override fun onTransact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean {
try {
val isSuccess = super.onTransact(code, data, reply, flags)
if (!isSuccess) {
GlobalState.log("VpnService disconnected")
handleDestroy()
}
return isSuccess
} catch (e: RemoteException) {
GlobalState.log("VpnService onTransact $e")
return false
}
}
val service: VpnService
get() = this@VpnService
}
override fun onBind(intent: Intent): IBinder {
return binder
override fun onBind(intent: Intent): IBinder? =
if (intent.action == SystemVpnService.SERVICE_INTERFACE) {
super.onBind(intent)
} else {
binder
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// Android starts always-on VPN through this callback instead of FlClash's bound-service
// path. Notify the app layer so it can restore Core and fully initialize the VPN service.
notifyVpnStartRequested()
return super.onStartCommand(intent, flags, startId)
}
override fun onRevoke() {
stop()
notifyVpnRevoked()
}
private fun handleStart(options: VpnOptions) {
val fd = with(Builder()) {
val cidr = IPV4_ADDRESS.toCIDR()
addAddress(cidr.address, cidr.prefixLength)
Log.d(
"addAddress", "address: ${cidr.address} prefixLength:${cidr.prefixLength}"
)
val routeAddress = options.getIpv4RouteAddress()
if (routeAddress.isNotEmpty()) {
try {
routeAddress.forEach { i ->
Log.d(
"addRoute4", "address: ${i.address} prefixLength:${i.prefixLength}"
)
addRoute(i.address, i.prefixLength)
}
} catch (_: Exception) {
addRoute(NET_ANY, 0)
}
} else {
addRoute(NET_ANY, 0)
}
if (options.ipv6) {
try {
val cidr = IPV6_ADDRESS.toCIDR()
Log.d(
"addAddress6", "address: ${cidr.address} prefixLength:${cidr.prefixLength}"
)
addAddress(cidr.address, cidr.prefixLength)
} catch (_: Exception) {
Log.d(
"addAddress6", "IPv6 is not supported."
)
}
try {
val routeAddress = options.getIpv6RouteAddress()
if (routeAddress.isNotEmpty()) {
try {
routeAddress.forEach { i ->
Log.d(
"addRoute6",
"address: ${i.address} prefixLength:${i.prefixLength}"
)
addRoute(i.address, i.prefixLength)
}
} catch (_: Exception) {
addRoute("::", 0)
}
} else {
addRoute(NET_ANY6, 0)
}
} catch (_: Exception) {
addRoute(NET_ANY6, 0)
}
}
addDnsServer(DNS)
if (options.ipv6) {
addDnsServer(DNS6)
}
setMtu(9000)
options.accessControlProps.let { accessControl ->
if (accessControl.enable) {
when (accessControl.mode) {
AccessControlMode.ACCEPT_SELECTED -> {
(accessControl.acceptList + packageName).forEach {
addAllowedApplication(it)
}
}
AccessControlMode.REJECT_SELECTED -> {
(accessControl.rejectList - packageName).forEach {
addDisallowedApplication(it)
}
}
}
}
}
setSession("FlClash")
addAddressAndRoutes(options)
addDnsServers(options)
setMtu(MTU)
configureAccessControl(options)
setSession(getString(CommonR.string.app_name))
setBlocking(false)
if (Build.VERSION.SDK_INT >= 29) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
setMetered(false)
}
if (options.allowBypass) {
allowBypass()
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && options.systemProxy) {
GlobalState.log("Open http proxy")
GlobalState.log("Enable VPN HTTP proxy")
setHttpProxy(
ProxyInfo.buildDirectProxy(
"127.0.0.1", options.port, options.bypassDomain
)
LOCAL_HOST,
options.port,
options.bypassDomain,
),
)
}
establish()?.detachFd()
?: throw NullPointerException("Establish VPN rejected by system")
?: error("VPN establishment was rejected by the system")
}
Core.startTun(
fd,
protect = this::protect,
resolverProcess = this::resolverProcess,
options.stack,
options.address,
options.dns
synchronized(tunLock) {
tunRunning = true
try {
Core.startTun(
fd = fd,
protect = this::protect,
resolverProcess = this::resolverProcess,
stack = options.stack,
address = options.tunAddress,
dns = options.tunDns,
)
} catch (error: Exception) {
stopTunLocked()
throw error
}
}
}
private fun Builder.addAddressAndRoutes(options: VpnOptions) {
val ipv4Address = IPV4_ADDRESS.toCIDR()
addAddress(ipv4Address.address, ipv4Address.prefixLength)
addRoutes(
routes = options::getIpv4RouteAddress,
fallbackAddress = NET_ANY,
logTag = "addRoute4",
)
if (options.ipv6) {
try {
val ipv6Address = IPV6_ADDRESS.toCIDR()
addAddress(ipv6Address.address, ipv6Address.prefixLength)
} catch (_: Exception) {
GlobalState.log("IPv6 VPN address is not supported")
}
addRoutes(
routes = options::getIpv6RouteAddress,
fallbackAddress = NET_ANY6,
logTag = "addRoute6",
)
}
}
private fun Builder.addRoutes(
routes: () -> List<CIDR>,
fallbackAddress: String,
logTag: String,
) {
val routeList = runCatching(routes).getOrDefault(emptyList())
if (routeList.isEmpty()) {
addRoute(fallbackAddress, 0)
return
}
try {
routeList.forEach { route ->
Log.d(logTag, "address: ${route.address} prefixLength: ${route.prefixLength}")
addRoute(route.address, route.prefixLength)
}
} catch (_: Exception) {
addRoute(fallbackAddress, 0)
}
}
private fun Builder.addDnsServers(options: VpnOptions) {
addDnsServer(DNS)
if (options.ipv6) {
addDnsServer(DNS6)
}
}
private fun Builder.configureAccessControl(options: VpnOptions) {
val accessControl = options.accessControlProps
if (!accessControl.enable) return
when (accessControl.mode) {
AccessControlMode.ACCEPT_SELECTED -> {
(accessControl.acceptList + packageName).forEach(::addAllowedApplication)
}
AccessControlMode.REJECT_SELECTED -> {
(accessControl.rejectList - packageName).forEach(::addDisallowedApplication)
}
}
}
override fun start() {
try {
loader.load()
State.options?.let {
handleStart(it)
}
} catch (_: Exception) {
modules.start()
handleStart(requireNotNull(ServiceConfig.vpnOptions) { "VPN options are missing" })
} catch (error: Exception) {
stop()
throw error
}
}
override fun stop() {
loader.cancel()
Core.stopTun()
stopSelf()
try {
cleanup()
} finally {
stopSelf()
}
}
private fun cleanup() {
try {
modules.stop()
} finally {
stopTun()
}
}
private fun stopTun() = synchronized(tunLock) {
stopTunLocked()
}
private fun stopTunLocked() {
if (tunRunning) {
Core.stopTun()
tunRunning = false
}
}
companion object {
@@ -257,5 +269,7 @@ class VpnService : SystemVpnService(), IBaseService,
private const val DNS6 = "fdfe:dcba:9876::2"
private const val NET_ANY = "0.0.0.0"
private const val NET_ANY6 = "::"
private const val LOCAL_HOST = "127.0.0.1"
private const val MTU = 9000
}
}
}
@@ -1,36 +1,7 @@
package com.follow.clash.service.models
import android.os.Parcel
import android.os.Parcelable
data class NotificationParams(
val title: String = "FlClash",
val stopText: String = "STOP",
val onlyStatisticsProxy: Boolean = false,
) : Parcelable {
constructor(parcel: Parcel) : this(
title = parcel.readString() ?: "FlClash",
stopText = parcel.readString() ?: "STOP",
onlyStatisticsProxy = parcel.readByte() != 0.toByte(),
)
override fun writeToParcel(parcel: Parcel, flags: Int) {
parcel.writeString(title)
parcel.writeString(stopText)
parcel.writeByte(if (onlyStatisticsProxy) 1.toByte() else 0.toByte())
}
override fun describeContents(): Int {
return 0
}
companion object CREATOR : Parcelable.Creator<NotificationParams> {
override fun createFromParcel(parcel: Parcel): NotificationParams {
return NotificationParams(parcel)
}
override fun newArray(size: Int): Array<NotificationParams?> {
return arrayOfNulls(size)
}
}
}
)
@@ -1,25 +1,39 @@
package com.follow.clash.service.models
import com.follow.clash.common.GlobalState
import com.follow.clash.common.formatBytes
import com.follow.clash.core.Core
import com.google.gson.Gson
private val gson = Gson()
data class Traffic(
val up: Long,
val down: Long,
)
private val Long.formatBytes: String
get() {
val units = arrayOf("B", "KB", "MB", "GB", "TB")
var value = toDouble()
var unit = 0
while (value >= 1024 && unit < units.lastIndex) {
value /= 1024
unit++
}
return if (unit == 0) {
"${value.toLong()}${units[unit]}"
} else {
"%.1f${units[unit]}".format(value)
}
}
val Traffic.speedText: String
get() = "${up.formatBytes}/s↑ ${down.formatBytes}/s↓"
fun Core.getSpeedTrafficText(onlyStatisticsProxy: Boolean): String {
try {
val res = getTraffic(onlyStatisticsProxy)
val traffic = Gson().fromJson(res, Traffic::class.java)
return traffic.speedText
} catch (e: Exception) {
GlobalState.log(e.message + "")
return ""
}
}
return runCatching {
gson.fromJson(getTraffic(onlyStatisticsProxy), Traffic::class.java).speedText
}.onFailure { error ->
GlobalState.log("Unable to read traffic: $error")
}.getOrDefault("")
}
@@ -1,8 +1,8 @@
package com.follow.clash.service.models
import android.os.Parcel
import android.os.Parcelable
import com.follow.clash.common.AccessControlMode
import java.net.Inet4Address
import java.net.Inet6Address
import java.net.InetAddress
data class AccessControlProps(
@@ -10,35 +10,7 @@ data class AccessControlProps(
val mode: AccessControlMode,
val acceptList: List<String>,
val rejectList: List<String>,
) : Parcelable {
constructor(parcel: Parcel) : this(
enable = parcel.readByte() != 0.toByte(),
mode = AccessControlMode.valueOf(parcel.readString() ?: AccessControlMode.ACCEPT_SELECTED.name),
acceptList = parcel.createStringArrayList() ?: emptyList(),
rejectList = parcel.createStringArrayList() ?: emptyList(),
)
override fun writeToParcel(parcel: Parcel, flags: Int) {
parcel.writeByte(if (enable) 1.toByte() else 0.toByte())
parcel.writeString(mode.name)
parcel.writeStringList(acceptList)
parcel.writeStringList(rejectList)
}
override fun describeContents(): Int {
return 0
}
companion object CREATOR : Parcelable.Creator<AccessControlProps> {
override fun createFromParcel(parcel: Parcel): AccessControlProps {
return AccessControlProps(parcel)
}
override fun newArray(size: Int): Array<AccessControlProps?> {
return arrayOfNulls(size)
}
}
}
)
data class VpnOptions(
val enable: Boolean,
@@ -51,110 +23,32 @@ data class VpnOptions(
val bypassDomain: List<String>,
val stack: String,
val routeAddress: List<String>,
) : Parcelable {
constructor(parcel: Parcel) : this(
enable = parcel.readByte() != 0.toByte(),
port = parcel.readInt(),
ipv6 = parcel.readByte() != 0.toByte(),
dnsHijacking = parcel.readByte() != 0.toByte(),
accessControlProps = readAccessControlProps(parcel),
allowBypass = parcel.readByte() != 0.toByte(),
systemProxy = parcel.readByte() != 0.toByte(),
bypassDomain = parcel.createStringArrayList() ?: emptyList(),
stack = parcel.readString() ?: "",
routeAddress = parcel.createStringArrayList() ?: emptyList(),
)
)
override fun writeToParcel(parcel: Parcel, flags: Int) {
parcel.writeByte(if (enable) 1.toByte() else 0.toByte())
parcel.writeInt(port)
parcel.writeByte(if (ipv6) 1.toByte() else 0.toByte())
parcel.writeByte(if (dnsHijacking) 1.toByte() else 0.toByte())
parcel.writeParcelable(accessControlProps, flags)
parcel.writeByte(if (allowBypass) 1.toByte() else 0.toByte())
parcel.writeByte(if (systemProxy) 1.toByte() else 0.toByte())
parcel.writeStringList(bypassDomain)
parcel.writeString(stack)
parcel.writeStringList(routeAddress)
}
data class CIDR(
val address: InetAddress,
val prefixLength: Int,
)
override fun describeContents(): Int {
return 0
}
fun VpnOptions.getIpv4RouteAddress(): List<CIDR> = routeAddress
.map(String::toCIDR)
.filter { it.address is Inet4Address }
companion object CREATOR : Parcelable.Creator<VpnOptions> {
@Suppress("DEPRECATION")
private fun readAccessControlProps(parcel: Parcel): AccessControlProps {
return parcel.readParcelable<AccessControlProps>(
AccessControlProps::class.java.classLoader,
) ?: AccessControlProps(
enable = false,
mode = AccessControlMode.ACCEPT_SELECTED,
acceptList = emptyList(),
rejectList = emptyList(),
)
}
override fun createFromParcel(parcel: Parcel): VpnOptions {
return VpnOptions(parcel)
}
override fun newArray(size: Int): Array<VpnOptions?> {
return arrayOfNulls(size)
}
}
}
data class CIDR(val address: InetAddress, val prefixLength: Int)
fun VpnOptions.getIpv4RouteAddress(): List<CIDR> {
return routeAddress.filter {
it.isIpv4()
}.map {
it.toCIDR()
}
}
fun VpnOptions.getIpv6RouteAddress(): List<CIDR> {
return routeAddress.filter {
it.isIpv6()
}.map {
it.toCIDR()
}
}
fun String.isIpv4(): Boolean {
val parts = split("/")
if (parts.size != 2) {
throw IllegalArgumentException("Invalid CIDR format")
}
val address = InetAddress.getByName(parts[0])
return address.address.size == 4
}
fun String.isIpv6(): Boolean {
val parts = split("/")
if (parts.size != 2) {
throw IllegalArgumentException("Invalid CIDR format")
}
val address = InetAddress.getByName(parts[0])
return address.address.size == 16
}
fun VpnOptions.getIpv6RouteAddress(): List<CIDR> = routeAddress
.map(String::toCIDR)
.filter { it.address is Inet6Address }
fun String.toCIDR(): CIDR {
val parts = split("/")
if (parts.size != 2) {
throw IllegalArgumentException("Invalid CIDR format")
}
require(parts.size == 2) { "Invalid CIDR format: $this" }
val ipAddress = parts[0]
val prefixLength =
parts[1].toIntOrNull() ?: throw IllegalArgumentException("Invalid prefix length")
val prefixLength = parts[1].toIntOrNull()
?: throw IllegalArgumentException("Invalid prefix length: ${parts[1]}")
val address = InetAddress.getByName(ipAddress)
val maxPrefix = if (address.address.size == 4) 32 else 128
if (prefixLength < 0 || prefixLength > maxPrefix) {
throw IllegalArgumentException("Invalid prefix length for IP version")
require(prefixLength in 0..maxPrefix) {
"Invalid prefix length $prefixLength for $ipAddress"
}
return CIDR(address, prefixLength)
@@ -1,19 +0,0 @@
package com.follow.clash.service.modules
abstract class Module {
private var isInstall: Boolean = false
protected abstract fun onInstall()
protected abstract fun onUninstall()
fun install() {
isInstall = true
onInstall()
}
fun uninstall() {
onUninstall()
isInstall = false
}
}
@@ -1,51 +0,0 @@
package com.follow.clash.service.modules
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
interface ModuleLoaderScope {
fun <T : Module> install(module: T): T
}
interface ModuleLoader {
fun load()
fun cancel()
}
private val mutex = Mutex()
fun CoroutineScope.moduleLoader(block: suspend ModuleLoaderScope.() -> Unit): ModuleLoader {
val modules = mutableListOf<Module>()
var job: Job? = null
return object : ModuleLoader {
override fun load() {
job = launch(Dispatchers.IO) {
mutex.withLock {
val scope = object : ModuleLoaderScope {
override fun <T : Module> install(module: T): T {
modules.add(module)
module.install()
return module
}
}
scope.block()
}
}
}
override fun cancel() {
launch(Dispatchers.IO) {
job?.cancel()
mutex.withLock {
modules.asReversed().forEach { it.uninstall() }
modules.clear()
}
}
}
}
}
@@ -17,18 +17,20 @@ import java.net.InetAddress
import java.util.concurrent.ConcurrentHashMap
private data class NetworkInfo(
@Volatile var losingMs: Long = 0, @Volatile var dnsList: List<InetAddress> = emptyList()
@Volatile var losingUntilMillis: Long = 0,
@Volatile var dnsList: List<InetAddress> = emptyList(),
) {
fun isAvailable(): Boolean = losingMs < System.currentTimeMillis()
val priorityPenalty: Int
get() = if (losingUntilMillis > System.currentTimeMillis()) 10 else 0
}
class NetworkObserveModule(private val service: Service) : Module() {
internal class NetworkObserveModule(private val service: Service) : ServiceModule {
private val networkInfos = ConcurrentHashMap<Network, NetworkInfo>()
private val connectivity by lazy {
service.getSystemService<ConnectivityManager>()
}
private var preDnsList = listOf<String>()
private var currentDnsList = listOf<String>()
private val request = NetworkRequest.Builder().apply {
addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
@@ -42,108 +44,79 @@ class NetworkObserveModule(private val service: Service) : Module() {
private val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
networkInfos[network] = NetworkInfo()
onUpdateNetwork()
super.onAvailable(network)
updateDns()
}
override fun onLosing(network: Network, maxMsToLive: Int) {
networkInfos[network]?.losingMs = System.currentTimeMillis() + maxMsToLive
onUpdateNetwork()
setUnderlyingNetworks(network)
super.onLosing(network, maxMsToLive)
networkInfos[network]?.losingUntilMillis = System.currentTimeMillis() + maxMsToLive
updateDns()
}
override fun onLost(network: Network) {
networkInfos.remove(network)
onUpdateNetwork()
setUnderlyingNetworks(network)
super.onLost(network)
updateDns()
}
override fun onLinkPropertiesChanged(network: Network, linkProperties: LinkProperties) {
networkInfos[network]?.dnsList = linkProperties.dnsServers
onUpdateNetwork()
setUnderlyingNetworks(network)
super.onLinkPropertiesChanged(network, linkProperties)
updateDns()
}
}
override fun onInstall() {
onUpdateNetwork()
override fun start() {
updateDns()
connectivity?.registerNetworkCallback(request, callback)
}
private fun networkToInt(entry: Map.Entry<Network, NetworkInfo>): Int {
private fun networkPriority(entry: Map.Entry<Network, NetworkInfo>): Int {
val capabilities = connectivity?.getNetworkCapabilities(entry.key)
return when {
capabilities == null -> 100
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> 90
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> 0
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> 1
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && capabilities.hasTransport(
TRANSPORT_USB
) -> 2
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
capabilities.hasTransport(TRANSPORT_USB) -> 2
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_BLUETOOTH) -> 3
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> 4
Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM && capabilities.hasTransport(
TRANSPORT_SATELLITE
) -> 5
Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM &&
capabilities.hasTransport(TRANSPORT_SATELLITE) -> 5
else -> 20
} + (if (entry.value.isAvailable()) 0 else 10)
} + entry.value.priorityPenalty
}
fun onUpdateNetwork() {
val dnsList = (networkInfos.asSequence().minByOrNull { networkToInt(it) }?.value?.dnsList
?: emptyList()).map { x -> x.asSocketAddressText(53) }
if (dnsList == preDnsList) {
@Synchronized
private fun updateDns() {
val dnsList = networkInfos.asSequence()
.minByOrNull(::networkPriority)
?.value
?.dnsList
.orEmpty()
.map { address -> address.asSocketAddressText(DNS_PORT) }
.distinct()
if (dnsList == currentDnsList) {
return
}
preDnsList = dnsList
Core.updateDNS(dnsList.toSet().joinToString(","))
currentDnsList = dnsList
Core.updateDNS(dnsList.joinToString(","))
}
fun setUnderlyingNetworks(network: Network) {
// if (service is VpnService && Build.VERSION.SDK_INT in 22..28) {
// service.setUnderlyingNetworks(arrayOf(network))
// }
}
override fun onUninstall() {
connectivity?.unregisterNetworkCallback(callback)
networkInfos.clear()
onUpdateNetwork()
}
}
fun InetAddress.asSocketAddressText(port: Int): String {
return when (this) {
is Inet6Address -> "[${numericToTextFormat(this)}]:$port"
is Inet4Address -> "${this.hostAddress}:$port"
else -> throw IllegalArgumentException("Unsupported Inet type ${this.javaClass}")
}
}
private fun numericToTextFormat(address: Inet6Address): String {
val src = address.address
val sb = StringBuilder(39)
for (i in 0 until 8) {
sb.append(
Integer.toHexString(
src[i shl 1].toInt() shl 8 and 0xff00 or (src[(i shl 1) + 1].toInt() and 0xff)
)
)
if (i < 7) {
sb.append(":")
override fun stop() {
try {
connectivity?.unregisterNetworkCallback(callback)
} finally {
networkInfos.clear()
updateDns()
}
}
if (address.scopeId > 0) {
sb.append("%")
sb.append(address.scopeId)
}
return sb.toString()
}
}
private const val DNS_PORT = 53
private fun InetAddress.asSocketAddressText(port: Int): String = when (this) {
is Inet6Address -> "[$hostAddress]:$port"
is Inet4Address -> "$hostAddress:$port"
else -> error("Unsupported address type: ${javaClass.name}")
}
@@ -14,39 +14,41 @@ import com.follow.clash.common.QuickAction
import com.follow.clash.common.quickIntent
import com.follow.clash.common.receiveBroadcastFlow
import com.follow.clash.common.startForeground
import com.follow.clash.common.tickerFlow
import com.follow.clash.common.toPendingIntent
import com.follow.clash.core.Core
import com.follow.clash.service.R
import com.follow.clash.service.State
import com.follow.clash.service.ServiceConfig
import com.follow.clash.service.models.NotificationParams
import com.follow.clash.service.models.getSpeedTrafficText
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
data class ExtendedNotificationParams(
private data class ExtendedNotificationParams(
val title: String,
val stopText: String,
val onlyStatisticsProxy: Boolean,
val contentText: String,
)
val NotificationParams.extended: ExtendedNotificationParams
private val NotificationParams.extended: ExtendedNotificationParams
get() = ExtendedNotificationParams(
title, stopText, onlyStatisticsProxy, Core.getSpeedTrafficText(onlyStatisticsProxy)
title,
stopText,
Core.getSpeedTrafficText(onlyStatisticsProxy),
)
class NotificationModule(private val service: Service) : Module() {
private val scope = CoroutineScope(Dispatchers.Default)
override fun onInstall() {
internal class NotificationModule(
private val service: Service,
private val scope: CoroutineScope,
) : ServiceModule {
override fun start() {
update(ServiceConfig.notificationParams.value.extended)
scope.launch {
val screenFlow = service.receiveBroadcastFlow {
addAction(Intent.ACTION_SCREEN_ON)
@@ -58,41 +60,36 @@ class NotificationModule(private val service: Service) : Module() {
}
combine(
tickerFlow(1000, 0), State.notificationParamsFlow, screenFlow
flow {
while (true) {
delay(1_000)
emit(Unit)
}
},
ServiceConfig.notificationParams,
screenFlow,
) { _, params, screenOn ->
params?.extended to screenOn
}.filter { (params, screenOn) -> params != null && screenOn }
.distinctUntilChanged { old, new -> old.first == new.first && old.second == new.second }
.collect { (params, _) ->
update(params!!)
}
State.notificationParamsFlow.value?.let {
update(it.extended)
} ?: run {
update(NotificationParams().extended)
}
params.takeIf { screenOn }?.extended
}.filterNotNull()
.distinctUntilChanged()
.collect(::update)
}
}
private fun isScreenOn(): Boolean {
val pm = service.getSystemService<PowerManager>()
return when (pm != null) {
true -> pm.isInteractive
false -> true
}
}
private fun isScreenOn() =
service.getSystemService<PowerManager>()?.isInteractive ?: true
private val notificationBuilder: NotificationCompat.Builder by lazy {
val intent = Intent().setComponent(Components.MAIN_ACTIVITY)
val intent = Intent().setComponent(Components.mainActivity)
NotificationCompat.Builder(
service, GlobalState.NOTIFICATION_CHANNEL
service,
GlobalState.NOTIFICATION_CHANNEL,
).apply {
setSmallIcon(R.drawable.ic_service)
setContentTitle("FlClash")
setContentIntent(intent.toPendingIntent)
setPriority(NotificationCompat.PRIORITY_HIGH)
setPriority(NotificationCompat.PRIORITY_LOW)
setCategory(NotificationCompat.CATEGORY_SERVICE)
setOngoing(true)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
@@ -100,9 +97,6 @@ class NotificationModule(private val service: Service) : Module() {
}
setShowWhen(true)
setOnlyAlertOnce(true)
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
// setRequestPromotedOngoing(true)
// }
}
}
@@ -113,17 +107,20 @@ class NotificationModule(private val service: Service) : Module() {
setContentText(params.contentText)
clearActions()
addAction(
0, params.stopText, QuickAction.STOP.quickIntent.toPendingIntent
0,
params.stopText,
QuickAction.STOP.quickIntent.toPendingIntent,
).build()
})
},
)
}
override fun onUninstall() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
@Suppress("DEPRECATION")
override fun stop() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
service.stopForeground(STOP_FOREGROUND_REMOVE)
} else {
service.stopForeground(true)
}
scope.cancel()
}
}
}
@@ -0,0 +1,59 @@
package com.follow.clash.service.modules
import android.app.Service
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
internal interface ServiceModule {
fun start()
fun stop()
}
internal class ServiceModules(private val service: Service) {
private var scope: CoroutineScope? = null
private var modules = emptyList<ServiceModule>()
@Synchronized
fun start() {
if (scope != null) return
val nextScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val nextModules = listOf(
NotificationModule(service, nextScope),
NetworkObserveModule(service),
SuspendModule(service, nextScope),
)
val startedModules = mutableListOf<ServiceModule>()
try {
nextModules.forEach { module ->
module.start()
startedModules.add(module)
}
scope = nextScope
modules = nextModules
} catch (error: Throwable) {
nextScope.cancel()
startedModules.asReversed().forEach { module ->
runCatching { module.stop() }
}
throw error
}
}
@Synchronized
fun stop() {
val currentScope = scope ?: return
val currentModules = modules
scope = null
modules = emptyList()
currentScope.cancel()
currentModules.asReversed().forEach { module ->
runCatching { module.stop() }
}
}
}
@@ -7,55 +7,41 @@ import androidx.core.content.getSystemService
import com.follow.clash.common.receiveBroadcastFlow
import com.follow.clash.core.Core
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
internal class SuspendModule(
private val service: Service,
private val scope: CoroutineScope,
) : ServiceModule {
private fun isScreenOn() =
service.getSystemService<PowerManager>()?.isInteractive ?: true
class SuspendModule(private val service: Service) : Module() {
private val scope = CoroutineScope(Dispatchers.Default)
private val isDeviceIdle: Boolean
get() = service.getSystemService<PowerManager>()?.isDeviceIdleMode ?: true
private fun isScreenOn(): Boolean {
val pm = service.getSystemService<PowerManager>()
return when (pm != null) {
true -> pm.isInteractive
false -> true
}
private fun updateSuspension(screenOn: Boolean) {
Core.suspended(!screenOn && isDeviceIdle)
}
val isDeviceIdleMode: Boolean
get() {
return service.getSystemService<PowerManager>()?.isDeviceIdleMode ?: true
}
private fun onUpdate(isScreenOn: Boolean) {
if (isScreenOn) {
Core.suspended(false)
return
}
Core.suspended(isDeviceIdleMode)
}
override fun onInstall() {
override fun start() {
scope.launch {
val screenFlow = service.receiveBroadcastFlow {
addAction(Intent.ACTION_SCREEN_ON)
addAction(Intent.ACTION_SCREEN_OFF)
}.map { intent ->
intent.action == Intent.ACTION_SCREEN_ON
addAction(PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED)
}.map {
isScreenOn()
}.onStart {
emit(isScreenOn())
}
screenFlow.collect {
onUpdate(it)
}
screenFlow.collect(::updateSuspension)
}
}
override fun onUninstall() {
scope.cancel()
override fun stop() {
Core.suspended(false)
}
}
}
+2 -3
View File
@@ -21,11 +21,10 @@ plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.0.1" apply false
id("org.jetbrains.kotlin.android") version "2.4.0" apply false
id("com.google.gms.google-services") version ("4.5.0") apply false
id("com.google.firebase.crashlytics") version ("3.0.7") apply false
id("com.google.gms.google-services") version "4.5.0" apply false
id("com.google.firebase.crashlytics") version "3.0.7" apply false
}
include(":app")
include(":core")
include(":service")

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