Compare commits
23 Commits
v0.8.90-pre.4
...
v0.8.96
| Author | SHA1 | Date | |
|---|---|---|---|
| e2f678909d | |||
| 7fb4f4f711 | |||
| 903e2b8879 | |||
| 2df430ca31 | |||
| aee7ee2643 | |||
| 9a99897864 | |||
| 4551e57ff4 | |||
| 1cffeaa29e | |||
| 5f2c12642b | |||
| c493df386d | |||
| 425d32b50d | |||
| 51e24ef9de | |||
| 7c831855ef | |||
| 7e7f1f89f3 | |||
| 5179d55dfb | |||
| ac2f6b919e | |||
| 45015f856b | |||
| 672eaccd35 | |||
| 2fbb96f5c1 | |||
| 243b3037d9 | |||
| 6e404ab19c | |||
| 2395a4b20c | |||
| d3c3f04062 |
@@ -0,0 +1,28 @@
|
||||
# Agent Configuration Model
|
||||
|
||||
Use the smallest durable surface that matches the job.
|
||||
|
||||
## Surfaces
|
||||
|
||||
- `AGENTS.md`: auto-loaded repository entry point. Keep it small and reserve it for always-on rules, routing, and high-priority expectations.
|
||||
- `.agents/*.md`: human- and agent-readable reference docs linked from `AGENTS.md`. Use these for detailed project context, commands, architecture, and conventions.
|
||||
- `.agents/skills/*/SKILL.md`: repo-scoped Codex skills. Use these for repeatable workflows that should be discoverable by name and description.
|
||||
- `.codex/config.toml`: trusted project Codex settings such as MCP, hooks, sandbox, approval, or model defaults.
|
||||
- `.codex/rules/*.rules`: command permission rules for running commands outside the sandbox. Do not mix these with coding conventions in `.agents/rules.md`.
|
||||
- `.codex/hooks.json` or `.codex/config.toml` hooks: lifecycle automation such as prompt checks, command validation, or final verification gates.
|
||||
|
||||
## Placement Rules
|
||||
|
||||
- Put stable team conventions in `AGENTS.md` only when every task must see them without opening another file.
|
||||
- Put detailed explanations in `.agents/*.md` and link them from `AGENTS.md`.
|
||||
- Put reusable task workflows in `.agents/skills/<skill-name>/SKILL.md`.
|
||||
- Put mechanical enforcement in linters, tests, hooks, or `.codex/rules`; do not rely on prose when tooling can enforce the rule.
|
||||
- Keep user-specific preferences out of the repository. They belong in user-level Codex configuration or user-level skills.
|
||||
|
||||
## Skill Authoring Rules
|
||||
|
||||
- Use lowercase hyphenated names.
|
||||
- Start descriptions with `Use when...`.
|
||||
- Describe trigger conditions in the description, not the workflow.
|
||||
- Keep `SKILL.md` lean; link to `.agents/*.md` for large reference material.
|
||||
- Add scripts only when deterministic behavior is needed repeatedly.
|
||||
@@ -0,0 +1,383 @@
|
||||
# Architecture
|
||||
|
||||
## Core Integration
|
||||
|
||||
The Go proxy core in `core/` operates in two modes.
|
||||
|
||||
Android lib mode:
|
||||
|
||||
- Go core is compiled as a C shared library, `libclash.so`, through `go build -buildmode=c-shared` with CGO.
|
||||
- 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`.
|
||||
- `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/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`: 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
|
||||
|
||||
Provider files in `lib/providers/`:
|
||||
|
||||
- `app.dart`: runtime/UI state, logs, traffic, delays, loading, navigation.
|
||||
- `config.dart`: persistent config providers, app settings, theme, VPN, proxy style.
|
||||
- `state.dart`: derived/computed providers, navigation, proxy, tray, color scheme.
|
||||
- `action.dart`: business logic notifiers, setup, backup, core lifecycle, proxy selection.
|
||||
- `database.dart`: Drift database provider wrappers.
|
||||
|
||||
`globalState` in `lib/state.dart` is a singleton holding app lifecycle, timers, theme, and start/stop state. Providers are generated into `lib/providers/generated/`.
|
||||
|
||||
## Database
|
||||
|
||||
The app uses Drift/SQLite in `lib/database/`. Current schema version is 2.
|
||||
|
||||
Tables:
|
||||
|
||||
- `Profiles`
|
||||
- `Scripts`
|
||||
- `Rules`
|
||||
- `ProfileRuleLinks` (`profile_rule_mapping`)
|
||||
- `ProxyGroups`
|
||||
- `IconRecords` (`icon_records`)
|
||||
|
||||
Rule scenes distinguish global added rules, profile added rules, profile custom rules, and disabled links. Rule and proxy-group ordering use fractional indexing.
|
||||
|
||||
Generated Drift output lives in `lib/database/generated/database.g.dart`. After schema changes, run code generation and add or update focused database tests under `test/database/` when converter or migration behavior changes.
|
||||
|
||||
## Manager Stack
|
||||
|
||||
Managers are nested `InheritedWidget`/`StatefulWidget` components in `lib/application.dart`:
|
||||
|
||||
```text
|
||||
AppEnvManager > StatusManager > ThemeManager
|
||||
> [Desktop: WindowManager > TrayManager > HotKeyManager > ProxyManager]
|
||||
> ConnectivityManager > CoreManager > AppStateManager
|
||||
> [Mobile: AndroidManager > VpnManager | Desktop: WindowHeaderContainer]
|
||||
```
|
||||
|
||||
Each manager in `lib/manager/` handles a specific platform concern. Desktop-only managers are conditionally inserted.
|
||||
|
||||
## Core Controller and Actions
|
||||
|
||||
`lib/core/controller.dart` (`CoreController`) is a singleton facade over `CoreHandlerInterface`. Public methods delegate to the platform-specific interface, either Android FFI or desktop socket. It has an `@visibleForTesting` constructor and `resetInstance()` for test injection.
|
||||
|
||||
`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, 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
|
||||
|
||||
Desktop:
|
||||
|
||||
- `WindowManager`
|
||||
- `TrayManager`
|
||||
- `HotKeyManager`
|
||||
- `ProxyManager`
|
||||
|
||||
Mobile:
|
||||
|
||||
- `AndroidManager`
|
||||
- `TileManager`
|
||||
- `VpnManager`
|
||||
|
||||
Shared:
|
||||
|
||||
- `ConnectivityManager`
|
||||
- `CoreManager`
|
||||
- `AppStateManager`
|
||||
- `StatusManager`
|
||||
- `ThemeManager`
|
||||
|
||||
## Build System
|
||||
|
||||
`setup.dart` is the release build orchestrator:
|
||||
|
||||
1. Writes `env.json` (`APP_ENV`).
|
||||
2. Activates `flutter_distributor` for packaging.
|
||||
3. Relies on the platform build hook to build the required Core artifacts before
|
||||
the native application is linked.
|
||||
|
||||
Go core building is handled by `build_tool`, a standalone Dart CLI in `plugins/setup/buildkit/build_tool/`.
|
||||
|
||||
Platform build hooks inside `flutter build` trigger `build_tool` automatically:
|
||||
|
||||
- macOS: podspec script phase, `build_pod.sh`, `build_tool macos`.
|
||||
- Linux: CMake include, `buildkit/cmake/buildkit.cmake`, `build_tool linux`.
|
||||
- Windows: CMake include, `buildkit/cmake/buildkit.cmake`, `build_tool windows`. CMake forwards the active configuration through `BUILDKIT_CONFIGURATION`.
|
||||
- Android: Gradle include, `buildkit/gradle/plugin.gradle`, `build_tool android`.
|
||||
|
||||
### Setup Build Harness Plugin
|
||||
|
||||
`plugins/setup/` is a build-time Flutter plugin, not a runtime Dart or FFI API. Its plugin shape exists so Flutter's native
|
||||
build graphs can run the Go/Rust build harness before platform consumers need the generated artifacts. Application code
|
||||
must not import or call it.
|
||||
|
||||
Responsibilities are deliberately split:
|
||||
|
||||
- CocoaPods, Gradle, and CMake hooks schedule a lightweight check on every native build. They do not decide which Go or
|
||||
Rust files are stale.
|
||||
- `buildkit/build_tool/` owns target resolution, input fingerprinting, compilation, output copying, and cache validation.
|
||||
- `core/` and `services/helper/` remain source owners; `libclash/` and Android `jniLibs`/header directories are generated
|
||||
output locations.
|
||||
- `setup.dart` remains the release/package orchestrator and does not pre-build
|
||||
platform artifacts or 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-time harness for Go core artifacts and the Windows Rust helper; no runtime Dart API.
|
||||
- `proxy`: system proxy configuration.
|
||||
- `rust_api`: runtime Flutter Rust Bridge FFI plugin built through Cargokit.
|
||||
- `tray_manager`: system tray fork/customization.
|
||||
- `wifi_ssid`: Wi-Fi SSID detection.
|
||||
- `window_ext`: window extensions.
|
||||
- `flutter_distributor`: app packaging/distribution.
|
||||
|
||||
## Rust Helper Service
|
||||
|
||||
`services/helper/` is a Windows-only privileged helper for starting the core as admin and managing TUN. It is built with:
|
||||
|
||||
```bash
|
||||
make core-windows
|
||||
```
|
||||
|
||||
The build tool always compiles the Helper in Rust release mode after calculating
|
||||
the SHA256 of the Core produced for the active Flutter configuration.
|
||||
|
||||
The helper owns its Windows Service Control Manager lifecycle through two elevated commands:
|
||||
|
||||
- `FlClashHelperService.exe install` stops and removes any stale registration, creates the auto-start service for the
|
||||
current executable path, starts it, and waits for the running state.
|
||||
- `FlClashHelperService.exe uninstall` stops the service, waits for shutdown, removes its registration, and is also used
|
||||
by the Windows package uninstaller.
|
||||
|
||||
The Dart layer only launches the helper's `install` command through `ShellExecuteW`; it does not compose `sc.exe`,
|
||||
`taskkill`, or `cmd.exe` command lines.
|
||||
|
||||
In every Flutter build mode `/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.
|
||||
@@ -0,0 +1,162 @@
|
||||
# Commands
|
||||
|
||||
## Building
|
||||
|
||||
Update submodules first. The ClashMeta Go core lives in `core/Clash.Meta/`.
|
||||
|
||||
```bash
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
|
||||
Full package build, including Go core, Flutter, and packaging, runs through `setup.dart`:
|
||||
|
||||
```bash
|
||||
dart setup.dart macos
|
||||
dart setup.dart linux
|
||||
dart setup.dart windows
|
||||
dart setup.dart android
|
||||
```
|
||||
|
||||
Build only the Go core and skip Flutter packaging:
|
||||
|
||||
```bash
|
||||
make core-macos
|
||||
make core-linux
|
||||
make core-windows
|
||||
make core-android
|
||||
```
|
||||
|
||||
Pass `ARCH` or `TARGET_PLATFORM` through `make` when needed, for example:
|
||||
|
||||
```bash
|
||||
make core-macos ARCH=arm64
|
||||
make core-android TARGET_PLATFORM=android-arm64
|
||||
```
|
||||
|
||||
Core builds use setup's input fingerprint cache. Pass `FORCE=1` to bypass it,
|
||||
for example `make core-macos ARCH=arm64 FORCE=1`.
|
||||
|
||||
The Makefile wraps `plugins/setup/buildkit/run_build_tool.sh`; prefer the `make` entry points unless debugging the build tool itself.
|
||||
|
||||
## Flutter Development
|
||||
|
||||
Use the default Flutter SDK directly:
|
||||
|
||||
```bash
|
||||
flutter pub get
|
||||
flutter run
|
||||
flutter test
|
||||
```
|
||||
|
||||
Use `flutter test`, not `dart test`, because models pull in Flutter types.
|
||||
|
||||
## Code Generation
|
||||
|
||||
Run code generation after modifying models, providers, or database schema:
|
||||
|
||||
```bash
|
||||
dart run build_runner build --delete-conflicting-outputs
|
||||
dart run build_runner watch
|
||||
```
|
||||
|
||||
Code generation covers:
|
||||
|
||||
- Riverpod providers through `riverpod_generator`.
|
||||
- Models through `freezed` and `json_serializable`.
|
||||
- Database tables through `drift_dev`.
|
||||
|
||||
Generated output paths, configured in `build.yaml`:
|
||||
|
||||
- `lib/models/generated/*.g.dart`, `*.freezed.dart`.
|
||||
- `lib/providers/generated/*.g.dart`.
|
||||
- `lib/database/generated/*.g.dart`.
|
||||
|
||||
## Testing
|
||||
|
||||
Tests use `package:test/test.dart` for pure Dart logic and `flutter_test` for provider and widget tests. `mocktail` is the mocking framework.
|
||||
|
||||
```bash
|
||||
flutter test test/models/
|
||||
flutter test test/core/
|
||||
flutter test test/core/desktop/
|
||||
flutter test test/providers/
|
||||
flutter test test/common/
|
||||
flutter test test/database/
|
||||
flutter test test/widgets/
|
||||
flutter test test/setup_test.dart
|
||||
flutter test plugins/proxy/test/proxy_test.dart
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
The tag-triggered release workflow runs these root-package checks in order:
|
||||
|
||||
```bash
|
||||
flutter pub get
|
||||
flutter analyze --no-fatal-infos
|
||||
flutter test --reporter expanded
|
||||
```
|
||||
|
||||
Run `flutter analyze` locally before committing when practical.
|
||||
|
||||
The 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.
|
||||
@@ -0,0 +1,28 @@
|
||||
# Project Context
|
||||
|
||||
FlClash is a multi-platform proxy client based on ClashMeta (mihomo), built with Flutter. It supports Android, Windows, macOS, and Linux, using a Material You design with Surfboard-like UI.
|
||||
|
||||
## Version Notes
|
||||
|
||||
- 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
|
||||
|
||||
Linux:
|
||||
|
||||
```bash
|
||||
sudo apt-get install libayatana-appindicator3-dev libkeybinder-3.0-dev
|
||||
```
|
||||
|
||||
Windows:
|
||||
|
||||
- GCC and Inno Setup.
|
||||
- `ANDROID_NDK` env var for Android builds.
|
||||
|
||||
macOS:
|
||||
|
||||
```bash
|
||||
npm install -g appdmg
|
||||
```
|
||||
@@ -0,0 +1,120 @@
|
||||
# Rules
|
||||
|
||||
These are repository coding and testing conventions. Codex command permission rules belong in `.codex/rules/*.rules`; see `.agents/agent-config.md` before adding those.
|
||||
|
||||
## Dart and Flutter Style
|
||||
|
||||
`analysis_options.yaml` enforces these non-default rules:
|
||||
|
||||
- `prefer_single_quotes: true`: always use single quotes.
|
||||
- `require_trailing_commas: true`: use trailing commas in multi-line argument lists.
|
||||
- `sort_child_properties_last: true`: `child:` must be the last named parameter.
|
||||
- `avoid_print: true`: do not use `print()` calls.
|
||||
- `prefer_const_constructors: true` and `prefer_const_declarations: true`.
|
||||
- `prefer_final_locals: true` and `prefer_final_in_for_each: true`.
|
||||
- `always_declare_return_types: true`.
|
||||
|
||||
Generated directories are excluded from analysis:
|
||||
|
||||
- `build/**`
|
||||
- `lib/l10n/intl/**`
|
||||
- `lib/**/generated/**`
|
||||
- `plugins/**`
|
||||
|
||||
## Comments
|
||||
|
||||
Comments are opt-in and reserved for the few places that genuinely need one. Density is the point: every comment that
|
||||
restates the code devalues the comments that carry real information, until readers skim past all of them. A file with
|
||||
three comments that matter is more readable than one with thirty.
|
||||
|
||||
### Writing Comments
|
||||
|
||||
- Never add a comment on your own initiative. This covers explanatory, narrative, TODO, section-divider, and
|
||||
documentation comments, in Dart, Kotlin, Swift, Go, Rust, YAML, Gradle, and any other file you touch.
|
||||
- Never annotate line by line or statement by statement, and never restate in prose what the code already says. If a
|
||||
block needs a comment per step, the block needs better names or a smaller decomposition instead.
|
||||
- When a change genuinely cannot be understood without a comment, do not write it silently. Explain what is unclear,
|
||||
propose the exact comment text, and wait for the user to approve it before adding it.
|
||||
- Delete commented-out code, stale version notes, and comments that only restate the code, whenever you edit the file
|
||||
that contains them. This does not need approval.
|
||||
- These are not comments and must be preserved: analyzer and linter directives (`// ignore:`, `// ignore_for_file:`,
|
||||
`// coverage:ignore`), license and copyright headers, code-generation markers, and comments inside vendored upstream
|
||||
code such as `lib/widgets/open_container.dart`.
|
||||
|
||||
### Where Knowledge Belongs
|
||||
|
||||
Pick the destination by where the constraint would be violated, not by how important it feels.
|
||||
|
||||
- **Assertable behavior goes in a test.** A test is the only form that cannot drift, because it fails when the behavior
|
||||
it describes is broken. Prefer it over both a comment and a document whenever the fact can be checked in code.
|
||||
- **Repository-wide defaults, ownership, and invariants go in `.agents/*.md` or a `.agents/skills/*/SKILL.md`.** They
|
||||
are violated from many files, so they must reach every future agent at session start. A comment in one file cannot do
|
||||
that.
|
||||
- **A fact that is true only at one call site, and is not visible from that call site, stays a comment there.** Its
|
||||
value is being in the reader's line of sight at the moment of the edit. `lib/common/constant.dart` is the model case:
|
||||
the delay-test concurrency cap is bound to `mBatch` in `core/common.go`, and whoever changes that number must see the
|
||||
constraint on the same screen.
|
||||
|
||||
Both failure directions are real. Moving a local constraint into `.agents/` hides it from the person editing the line;
|
||||
leaving a repo-wide policy as a comment reaches only the reader of that one file.
|
||||
|
||||
Before any of the three, prefer encoding the intent in structure and naming — a named mixin, type, or method that makes
|
||||
the invariant hard to break beats prose that asks the next reader not to break it.
|
||||
|
||||
## 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.
|
||||
|
||||
Use `ProviderContainer` directly for simple Riverpod provider tests. The generated Riverpod `update()` method takes a callback:
|
||||
|
||||
```dart
|
||||
notifier.update((state) => newValue);
|
||||
```
|
||||
|
||||
When testing freezed models with nested objects, always round-trip through `jsonEncode` and `jsonDecode`. Direct `fromJson(toJson())` fails for nested freezed types because `toJson()` stores child objects directly instead of maps.
|
||||
|
||||
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/`
|
||||
- `lib/l10n/intl/`
|
||||
|
||||
After schema, model, or provider changes, run build generation and include focused tests when behavior changes.
|
||||
@@ -0,0 +1,18 @@
|
||||
# Agent Skills
|
||||
|
||||
Repo-scoped Codex skills live under `.agents/skills/*/SKILL.md`. Codex can discover these by skill `name` and `description`, then load the full instructions only when a task matches.
|
||||
|
||||
## Available Repo Skills
|
||||
|
||||
- `localization`: hardcoded UI text scans, ARB updates, locale generation, and localization verification.
|
||||
- `provider-tests`: Riverpod provider, notifier, and state-management tests.
|
||||
- `ui-work`: Flutter UI, widgets, Material You styling, navigation surfaces, 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
|
||||
|
||||
- Add new repeatable workflows as `.agents/skills/<skill-name>/SKILL.md`.
|
||||
- Keep skill descriptions trigger-focused and start them with `Use when...`.
|
||||
- Keep long reference material in `.agents/*.md`; skills should link to it instead of duplicating it.
|
||||
- Put command permission rules in `.codex/rules/*.rules`, not in this file.
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
name: core-platform
|
||||
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/`, Android app/service modules, build
|
||||
hooks, system proxy, tray, VPN, TUN, or platform-specific desktop/mobile behavior.
|
||||
|
||||
## Workflow
|
||||
|
||||
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
|
||||
|
||||
Read `.agents/architecture.md` for the current core modes, manager stack, build hooks, local plugins, and Windows helper notes.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- Keep the Windows Helper protocol and Core SHA256 validation identical across
|
||||
Flutter build modes; the Helper owns executable integrity checks.
|
||||
- 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.
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
name: localization
|
||||
description: Use when changing FlClash UI text, scanning for hardcoded Chinese strings, updating ARB localization, or fixing generated locale output in this repository.
|
||||
---
|
||||
|
||||
# Localization
|
||||
|
||||
## When To Use
|
||||
|
||||
Use this for localization work inside `/Users/Shared/follow/FlClash`, especially hardcoded UI text in `lib/`, ARB updates, missing translations, or generated `lib/l10n/` output that does not match source ARB values.
|
||||
|
||||
Do not use this for README translation sync or manual edits to generated localization Dart.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Confirm `pubspec.yaml` still uses `flutter_intl`, source ARBs under `arb/`, and generated output under `lib/l10n/`.
|
||||
2. Scan user-facing Dart text before opening many files:
|
||||
|
||||
```bash
|
||||
rg -n "[\\p{Han}]" lib -g '!lib/l10n/intl/**' -g '!lib/**/generated/**'
|
||||
```
|
||||
|
||||
3. Inspect the smallest relevant call sites and nearby ARB keys.
|
||||
4. Add or update every source ARB:
|
||||
- `arb/intl_en.arb`
|
||||
- `arb/intl_zh_CN.arb`
|
||||
- `arb/intl_ja.arb`
|
||||
- `arb/intl_ru.arb`
|
||||
5. Replace inline strings with existing project accessors:
|
||||
- Widgets with `BuildContext`: `context.appLocalizations.key` from `common.dart`.
|
||||
- Controllers/providers/non-widget code: `currentAppLocalizations.key` from `app_localizations.dart`.
|
||||
6. Regenerate:
|
||||
|
||||
```bash
|
||||
dart run intl_utils:generate
|
||||
```
|
||||
|
||||
7. Verify changed Dart files with `flutter analyze` when practical.
|
||||
8. Re-run the Han-text scan for targeted strings.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- If ja/ru still show English, fix ja/ru source ARB values and regenerate. Do not edit generated Dart.
|
||||
- Ignore `lib/l10n/intl/**` and `lib/**/generated/**` during text scans.
|
||||
- If generator or analyzer hits local cache permission friction, rerun serially before treating it as a code issue.
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
name: provider-tests
|
||||
description: Use when adding or updating FlClash Riverpod provider tests, notifier tests, or state-management tests in this repository.
|
||||
---
|
||||
|
||||
# Provider Tests
|
||||
|
||||
## When To Use
|
||||
|
||||
Use this for tests under `test/providers/` or any change that validates Riverpod providers, generated notifiers, app state defaults, or provider interactions.
|
||||
|
||||
For broader test expansion, pair this with `.agents/rules.md` and `.agents/commands.md`.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Read the provider under test and its generated public API before writing assertions.
|
||||
2. Use `ProviderContainer` directly when no widget tree is needed.
|
||||
3. Dispose containers in teardown or with `addTearDown(container.dispose)`.
|
||||
4. Prefer generated notifier APIs over implementation details. Generated `update()` takes a callback:
|
||||
|
||||
```dart
|
||||
notifier.update((state) => newValue);
|
||||
```
|
||||
|
||||
5. Mock external dependencies with `mocktail`; register fallback values for freezed params used with `any()`.
|
||||
6. Keep tests focused on behavior: defaults, state transitions, persistence boundaries, and side effects.
|
||||
7. Run the narrowest relevant test first:
|
||||
|
||||
```bash
|
||||
flutter test test/providers/
|
||||
```
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- Do not use `dart test`; FlClash models and provider tests may depend on Flutter types.
|
||||
- Re-check source defaults before asserting them; provider defaults can drift.
|
||||
- If async provider timing matters, wait on provider futures or state changes instead of fixed sleeps.
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
name: ui-work
|
||||
description: Use when changing FlClash Flutter UI, widgets, screens, Material You styling, navigation surfaces, async feedback, or user-facing interactions.
|
||||
---
|
||||
|
||||
# UI Work
|
||||
|
||||
## When To Use
|
||||
|
||||
Use this for user-facing Flutter UI changes in `lib/`, including widgets, screens, navigation surfaces, settings rows, dialogs, and interaction behavior.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Locate existing nearby widgets and reuse their patterns before adding new abstractions.
|
||||
2. Follow current Material You and Surfboard-like visual conventions.
|
||||
3. Use existing providers, notifiers, and helpers where possible.
|
||||
4. Keep `child:` last in widget constructors.
|
||||
5. Prefer `const` constructors and final locals.
|
||||
6. Localize user-facing text through ARB; use `localization` when text changes are non-trivial.
|
||||
7. Add focused widget tests when behavior changes, especially for rendering states, taps, scrolling, and empty/error states.
|
||||
8. 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
|
||||
flutter test test/widgets/
|
||||
```
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- Do not introduce a new visual system for one screen.
|
||||
- Do not manually edit generated localization or provider files.
|
||||
- Avoid broad layout rewrites unless the requested change requires them.
|
||||
- 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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
# CodeGraph data files — local to each machine, not for committing.
|
||||
# Ignore everything in .codegraph/ except this file itself, so transient
|
||||
# files (the database, daemon.pid, sockets, logs) never show up in git.
|
||||
*
|
||||
!.gitignore
|
||||
@@ -0,0 +1,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.
|
||||
"""
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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"
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"codegraph": {
|
||||
"type": "stdio",
|
||||
"command": "codegraph",
|
||||
"args": [
|
||||
"serve",
|
||||
"--mcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
cask "flclash" do
|
||||
version "VERSION"
|
||||
|
||||
on_macos do
|
||||
arch arm: "arm64", intel: "amd64"
|
||||
|
||||
sha256 arm: "ARM_SHA256",
|
||||
intel: "AMD_SHA256"
|
||||
|
||||
url "https://github.com/chen08209/FlClash/releases/download/v#{version}/FlClash-#{version}-macos-#{arch}.dmg"
|
||||
end
|
||||
|
||||
name "FlClash"
|
||||
desc "Multi-platform proxy client based on ClashMeta"
|
||||
homepage "https://github.com/chen08209/FlClash"
|
||||
|
||||
livecheck do
|
||||
url :url
|
||||
strategy :github_latest
|
||||
end
|
||||
|
||||
depends_on :macos
|
||||
|
||||
app "FlClash.app"
|
||||
|
||||
postflight do
|
||||
system_command "xattr",
|
||||
args: ["-rd", "com.apple.quarantine", "#{appdir}/FlClash.app"]
|
||||
end
|
||||
|
||||
uninstall quit: "com.follow.clash"
|
||||
|
||||
zap trash: [
|
||||
"~/Library/Application Support/com.follow.clash",
|
||||
"~/Library/Caches/com.follow.clash",
|
||||
"~/Library/Preferences/com.follow.clash.plist",
|
||||
"~/Library/Saved Application State/com.follow.clash.savedState",
|
||||
]
|
||||
end
|
||||
@@ -42,7 +42,7 @@
|
||||
<td>
|
||||
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-linux-amd64.AppImage"><img src="https://img.shields.io/badge/AppImage-x64-f84e29.svg?logo=linux"> </a><br>
|
||||
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-linux-amd64.deb"><img src="https://img.shields.io/badge/DebPackage-x64-FF9966.svg?logo=debian"> </a><br>
|
||||
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-linux-amd64.deb"><img src="https://img.shields.io/badge/RpmPackage-x64-F1B42F.svg?logo=redhat"> </a>
|
||||
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-linux-amd64.rpm"><img src="https://img.shields.io/badge/RpmPackage-x64-F1B42F.svg?logo=redhat"> </a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
@@ -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"
|
||||
+196
-84
@@ -6,9 +6,105 @@ on:
|
||||
- 'v*'
|
||||
env:
|
||||
IS_STABLE: ${{ !contains(github.ref, '-') }}
|
||||
FLUTTER_VERSION: '3.44.4'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Test release notes generation
|
||||
shell: bash
|
||||
run: bash .github/scripts/generate_release_notes_test.sh
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: stable
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Analyze
|
||||
run: flutter analyze --no-fatal-infos
|
||||
|
||||
- name: Run tests
|
||||
run: flutter test --reporter expanded
|
||||
|
||||
- name: Validate local Flutter packages
|
||||
shell: bash
|
||||
run: |
|
||||
for package in plugins/proxy plugins/wifi_ssid plugins/window_ext plugins/setup; do
|
||||
(
|
||||
cd "$package"
|
||||
flutter pub get
|
||||
flutter analyze --no-fatal-infos
|
||||
)
|
||||
done
|
||||
|
||||
- name: Run local plugin tests
|
||||
shell: bash
|
||||
run: |
|
||||
(
|
||||
cd plugins/proxy
|
||||
flutter test --reporter expanded
|
||||
)
|
||||
(
|
||||
cd plugins/wifi_ssid
|
||||
flutter test --reporter expanded
|
||||
)
|
||||
|
||||
- name: Validate setup build tool
|
||||
working-directory: plugins/setup/buildkit/build_tool
|
||||
run: |
|
||||
dart pub get
|
||||
dart analyze
|
||||
dart test
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.26.4'
|
||||
cache-dependency-path: core/go.sum
|
||||
|
||||
- name: Validate Go core wrapper
|
||||
working-directory: core
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
run: |
|
||||
go test .
|
||||
go vet .
|
||||
|
||||
- name: Validate Rust components
|
||||
run: |
|
||||
cargo fmt --manifest-path services/helper/Cargo.toml -- --check
|
||||
cargo test --manifest-path services/helper/Cargo.toml
|
||||
cargo fmt --manifest-path plugins/rust_api/rust/Cargo.toml -- --check
|
||||
cargo test --manifest-path plugins/rust_api/rust/Cargo.toml
|
||||
|
||||
windows-helper-test:
|
||||
name: Windows helper test
|
||||
runs-on: windows-2022
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check format
|
||||
run: cargo fmt --manifest-path services/helper/Cargo.toml -- --check
|
||||
|
||||
- name: Run Windows service tests
|
||||
run: cargo test --manifest-path services/helper/Cargo.toml --features windows-service
|
||||
|
||||
build:
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
|
||||
needs: [ test, windows-helper-test ]
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -22,14 +118,14 @@ jobs:
|
||||
os: ubuntu-22.04
|
||||
arch: amd64
|
||||
- platform: macos
|
||||
os: macos-13
|
||||
os: macos-15-intel
|
||||
arch: amd64
|
||||
- platform: macos
|
||||
os: macos-latest
|
||||
arch: arm64
|
||||
# - platform: windows
|
||||
# os: windows-11-arm
|
||||
# arch: arm64
|
||||
- platform: windows
|
||||
os: windows-11-arm
|
||||
arch: arm64
|
||||
- platform: linux
|
||||
os: ubuntu-24.04-arm
|
||||
arch: arm64
|
||||
@@ -37,6 +133,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup rust
|
||||
if: startsWith(matrix.os, 'windows-11-arm')
|
||||
shell: pwsh
|
||||
run: |
|
||||
Invoke-WebRequest -Uri "https://win.rustup.rs/aarch64" -OutFile rustup-init.exe
|
||||
.\rustup-init.exe -y --default-toolchain stable
|
||||
@@ -53,36 +150,51 @@ jobs:
|
||||
run: |
|
||||
echo "${{ secrets.KEYSTORE }}" | base64 --decode > android/app/keystore.jks
|
||||
echo "${{ secrets.SERVICE_JSON }}" | base64 --decode > android/app/google-services.json
|
||||
echo "keyAlias=${{ secrets.KEY_ALIAS }}" >> android/local.properties
|
||||
echo "storePassword=${{ secrets.STORE_PASSWORD }}" >> android/local.properties
|
||||
echo "keyPassword=${{ secrets.KEY_PASSWORD }}" >> android/local.properties
|
||||
{
|
||||
echo "keyAlias=${{ secrets.KEY_ALIAS }}"
|
||||
echo "storePassword=${{ secrets.STORE_PASSWORD }}"
|
||||
echo "keyPassword=${{ secrets.KEY_PASSWORD }}"
|
||||
} >> android/local.properties
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.24.0'
|
||||
go-version: '1.26.4'
|
||||
cache-dependency-path: |
|
||||
core/go.sum
|
||||
|
||||
- name: Setup Flutter Master
|
||||
if: startsWith(matrix.os, 'windows-11-arm') || startsWith(matrix.os, 'ubuntu-24.04-arm')
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: 'master'
|
||||
cache: true
|
||||
- name: Enable git long paths (Windows)
|
||||
if: startsWith(matrix.os, 'windows')
|
||||
run: git config --global core.longpaths true
|
||||
|
||||
- name: Setup Flutter
|
||||
if: ${{ !(startsWith(matrix.os, 'windows-11-arm') || startsWith(matrix.os, 'ubuntu-24.04-arm')) }}
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: 'stable'
|
||||
channel: master
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
# flutter-version: 3.29.3
|
||||
|
||||
- name: Setup Xcode
|
||||
if: startsWith(matrix.platform,'macos')
|
||||
run: |
|
||||
sudo xcode-select -s /Applications/Xcode_26.3.app || sudo xcode-select -s /Applications/Xcode_26.2.app
|
||||
xcodebuild -version
|
||||
|
||||
- name: Setup NDK
|
||||
if: startsWith(matrix.platform,'android')
|
||||
uses: nttld/setup-ndk@v1
|
||||
with:
|
||||
ndk-version: r28c
|
||||
add-to-path: true
|
||||
link-to-sdk: true
|
||||
|
||||
- name: Get Flutter Dependency
|
||||
run: flutter pub get
|
||||
run: |
|
||||
flutter --version
|
||||
flutter pub get
|
||||
|
||||
- name: Setup
|
||||
run: dart setup.dart ${{ matrix.platform }} ${{ matrix.arch && format('--arch {0}', matrix.arch) }} ${{ env.IS_STABLE == 'true' && '--env stable' || '' }}
|
||||
run: dart setup.dart ${{ matrix.platform }} ${{ env.IS_STABLE == 'true' && '--env stable' || '' }} -v
|
||||
|
||||
- name: Upload
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -92,49 +204,42 @@ jobs:
|
||||
overwrite: true
|
||||
|
||||
changelog:
|
||||
if: ${{ !contains(github.ref, '-') }}
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ build ]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
if: ${{ env.IS_STABLE == 'true' }}
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: refs/heads/main
|
||||
- name: Generate
|
||||
if: ${{ env.IS_STABLE == 'true' }}
|
||||
run: |
|
||||
tags=($(git tag --merged $(git rev-parse HEAD) --sort=-creatordate))
|
||||
preTag=$(grep -oP '^## \K.*' CHANGELOG.md | head -n 1)
|
||||
currentTag=""
|
||||
for ((i = 0; i <= ${#tags[@]}; i++)); do
|
||||
if (( i < ${#tags[@]} )); then
|
||||
tag=${tags[$i]}
|
||||
else
|
||||
tag=""
|
||||
fi
|
||||
if [ -n "$currentTag" ]; then
|
||||
if [ "$(echo -e "$currentTag\n$preTag" | sort -V | head -n 1)" == "$currentTag" ]; then
|
||||
break
|
||||
fi
|
||||
fi
|
||||
if [ -n "$currentTag" ]; then
|
||||
echo "## $currentTag" >> NEW_CHANGELOG.md
|
||||
echo "" >> NEW_CHANGELOG.md
|
||||
if [ -n "$tag" ]; then
|
||||
git log --pretty=format:"%B" "$tag..$currentTag" | awk 'NF {print "- " $0} !NF {print ""}' >> NEW_CHANGELOG.md
|
||||
else
|
||||
git log --pretty=format:"%B" "$currentTag" | awk 'NF {print "- " $0} !NF {print ""}' >> NEW_CHANGELOG.md
|
||||
fi
|
||||
echo "" >> NEW_CHANGELOG.md
|
||||
fi
|
||||
currentTag=$tag
|
||||
last_ver=$(grep -m1 '^## ' CHANGELOG.md 2>/dev/null | sed 's/^## //')
|
||||
|
||||
mapfile -t tags < <(git tag --merged HEAD --sort=-creatordate)
|
||||
|
||||
temp="NEW_CHANGELOG.md"
|
||||
: > "$temp"
|
||||
|
||||
for i in "${!tags[@]}"; do
|
||||
curr="${tags[i]}"
|
||||
[[ "$curr" == "$last_ver" ]] && break
|
||||
|
||||
prev="${tags[i+1]}"
|
||||
range="${prev:+$prev..}$curr"
|
||||
|
||||
echo -e "## $curr\n" >> "$temp"
|
||||
git log --no-merges --pretty=format:"%B" "$range" | \
|
||||
awk '!/Update changelog/ && NF {print "- " $0 "\n"}' >> "$temp"
|
||||
done
|
||||
cat CHANGELOG.md >> NEW_CHANGELOG.md
|
||||
cat NEW_CHANGELOG.md > CHANGELOG.md
|
||||
[ -f CHANGELOG.md ] && cat CHANGELOG.md >> "$temp"
|
||||
|
||||
mv "$temp" CHANGELOG.md
|
||||
|
||||
- name: Commit
|
||||
if: ${{ env.IS_STABLE == 'true' }}
|
||||
run: |
|
||||
git add CHANGELOG.md
|
||||
if ! git diff --cached --quiet; then
|
||||
@@ -142,8 +247,7 @@ jobs:
|
||||
git config --local user.email "chen08209@gmail.com"
|
||||
git config --local user.name "chen08209"
|
||||
git commit -m "Update changelog"
|
||||
git push
|
||||
if [ $? -eq 0 ]; then
|
||||
if git push; then
|
||||
echo "Push succeeded"
|
||||
else
|
||||
echo "Push failed"
|
||||
@@ -155,7 +259,8 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
upload:
|
||||
permissions: write-all
|
||||
permissions:
|
||||
contents: write
|
||||
needs: [ build ]
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
@@ -180,32 +285,13 @@ jobs:
|
||||
merge-multiple: true
|
||||
|
||||
- name: Generate release.md
|
||||
shell: bash
|
||||
run: |
|
||||
tags=($(git tag --merged $(git rev-parse HEAD) --sort=-creatordate))
|
||||
preTag=$(curl --silent "https://api.github.com/repos/chen08209/FlClash/releases/latest" | grep -Po '"tag_name": "\K.*?(?=")' || echo "")
|
||||
currentTag=""
|
||||
for ((i = 0; i <= ${#tags[@]}; i++)); do
|
||||
if (( i < ${#tags[@]} )); then
|
||||
tag=${tags[$i]}
|
||||
else
|
||||
tag=""
|
||||
fi
|
||||
if [ -n "$currentTag" ]; then
|
||||
if [ "$(echo -e "$currentTag\n$preTag" | sort -V | head -n 1)" == "$currentTag" ]; then
|
||||
break
|
||||
fi
|
||||
fi
|
||||
if [ -n "$currentTag" ]; then
|
||||
if [ -n "$tag" ]; then
|
||||
git log --pretty=format:"%B" "$tag..$currentTag" | awk 'NF {print "- " $0} !NF {print ""}' >> release.md
|
||||
else
|
||||
git log --pretty=format:"%B" "$currentTag" | awk 'NF {print "- " $0} !NF {print ""}' >> release.md
|
||||
fi
|
||||
echo "" >> release.md
|
||||
fi
|
||||
currentTag=$tag
|
||||
done
|
||||
|
||||
pre_tag=$(
|
||||
curl --silent "https://api.github.com/repos/chen08209/FlClash/releases/latest" |
|
||||
sed -nE 's/.*"tag_name": "([^"]+)".*/\1/p'
|
||||
)
|
||||
bash .github/scripts/generate_release_notes.sh "$pre_tag" release.md
|
||||
- name: Push to telegram
|
||||
env:
|
||||
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
@@ -222,12 +308,10 @@ jobs:
|
||||
sed "s|VERSION|$version|g" ./.github/release_template.md >> release.md
|
||||
|
||||
- name: Generate sha256
|
||||
if: env.IS_STABLE == 'true'
|
||||
if: ${{ env.IS_STABLE == 'true' }}
|
||||
run: |
|
||||
cd ./dist
|
||||
for file in $(find . -type f -not -name "*.sha256"); do
|
||||
sha256sum "$file" > "${file}.sha256"
|
||||
done
|
||||
sha256sum ./* > SHA256SUMS
|
||||
|
||||
- name: Release
|
||||
if: ${{ env.IS_STABLE == 'true' }}
|
||||
@@ -236,6 +320,35 @@ jobs:
|
||||
files: ./dist/*
|
||||
body_path: './release.md'
|
||||
|
||||
- name: Create Homebrew Cask Source Dir
|
||||
if: ${{ env.IS_STABLE == 'true' }}
|
||||
run: |
|
||||
version=$(echo "${{ github.ref_name }}" | sed 's/^v//')
|
||||
arm_sha=$(sha256sum "./dist/FlClash-${version}-macos-arm64.dmg" | awk '{print $1}')
|
||||
amd_sha=$(sha256sum "./dist/FlClash-${version}-macos-amd64.dmg" | awk '{print $1}')
|
||||
|
||||
mkdir -p ./brew/Casks
|
||||
sed \
|
||||
-e "s|VERSION|$version|g" \
|
||||
-e "s|ARM_SHA256|$arm_sha|g" \
|
||||
-e "s|AMD_SHA256|$amd_sha|g" \
|
||||
./.github/homebrew_cask_template.rb > ./brew/Casks/flclash.rb
|
||||
|
||||
- name: Push to Homebrew tap
|
||||
if: ${{ env.IS_STABLE == 'true' }}
|
||||
uses: cpina/github-action-push-to-another-repository@v1.7.2
|
||||
env:
|
||||
SSH_DEPLOY_KEY: ${{ secrets.HOMEBREW_SSH_DEPLOY_KEY }}
|
||||
with:
|
||||
source-directory: ./brew/
|
||||
destination-github-username: chen08209
|
||||
destination-repository-name: homebrew-tap
|
||||
user-name: 'chen08029'
|
||||
user-email: 'chen08209@gmail.com'
|
||||
target-branch: main
|
||||
commit-message: Update Homebrew cask from ${{ github.ref_name }}
|
||||
target-directory: /
|
||||
|
||||
- name: Create Fdroid Source Dir
|
||||
if: ${{ env.IS_STABLE == 'true' }}
|
||||
run: |
|
||||
@@ -252,9 +365,8 @@ jobs:
|
||||
source-directory: ./tmp/
|
||||
destination-github-username: chen08209
|
||||
destination-repository-name: FlClash-fdroid-repo
|
||||
user-name: 'github-actions[bot]'
|
||||
user-email: 'github-actions[bot]@users.noreply.github.com'
|
||||
user-name: 'chen08029'
|
||||
user-email: 'chen08209@gmail.com'
|
||||
target-branch: main
|
||||
commit-message: Update from ${{ github.ref_name }}
|
||||
target-directory: /tmp/
|
||||
|
||||
|
||||
+17
-2
@@ -21,7 +21,7 @@ migrate_working_dir/
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
**/doc/api/
|
||||
@@ -41,6 +41,13 @@ app.*.symbols
|
||||
# Obfuscation related
|
||||
app.*.map.json
|
||||
|
||||
# Claude Code - ignore user-specific settings only
|
||||
.claude/settings.local.json
|
||||
|
||||
# Documentation (generated/planning artifacts)
|
||||
docs/
|
||||
|
||||
|
||||
# Android Studio will place build artifacts here
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
@@ -53,11 +60,19 @@ app.*.map.json
|
||||
/android/core/**/cmake-build-*/
|
||||
/android/core/**/jniLibs/
|
||||
|
||||
|
||||
#FlClash
|
||||
/libclash/
|
||||
/android/app/src/main/jniLibs/
|
||||
/services/helper/target
|
||||
/macos/**/Package.resolved
|
||||
/macos/build/
|
||||
/env.json
|
||||
/core_sha256.json
|
||||
/core/*.exe
|
||||
/plugins/rust_api/.claude/
|
||||
devtools_options.yaml
|
||||
|
||||
# FVM Version Cache
|
||||
.fvm/
|
||||
.fvmrc
|
||||
/coverage/lcov.info
|
||||
|
||||
@@ -2,9 +2,3 @@
|
||||
path = core/Clash.Meta
|
||||
url = git@github.com:chen08209/Clash.Meta.git
|
||||
branch = FlClash
|
||||
[submodule "plugins/flutter_distributor"]
|
||||
path = plugins/flutter_distributor
|
||||
url = git@github.com:chen08209/flutter_distributor.git
|
||||
branch = FlClash
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"codegraph": {
|
||||
"type": "stdio",
|
||||
"command": "codegraph",
|
||||
"args": [
|
||||
"serve",
|
||||
"--mcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled.
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: 796c8ef79279f9c774545b3771238c3098dbefab
|
||||
channel: stable
|
||||
revision: "adc901062556672b4138e18a4dc62a4be8f4b3c2"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
|
||||
@@ -13,26 +13,11 @@ project_type: app
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: 796c8ef79279f9c774545b3771238c3098dbefab
|
||||
base_revision: 796c8ef79279f9c774545b3771238c3098dbefab
|
||||
- platform: android
|
||||
create_revision: 796c8ef79279f9c774545b3771238c3098dbefab
|
||||
base_revision: 796c8ef79279f9c774545b3771238c3098dbefab
|
||||
- platform: ios
|
||||
create_revision: 796c8ef79279f9c774545b3771238c3098dbefab
|
||||
base_revision: 796c8ef79279f9c774545b3771238c3098dbefab
|
||||
- platform: linux
|
||||
create_revision: 796c8ef79279f9c774545b3771238c3098dbefab
|
||||
base_revision: 796c8ef79279f9c774545b3771238c3098dbefab
|
||||
- platform: macos
|
||||
create_revision: 796c8ef79279f9c774545b3771238c3098dbefab
|
||||
base_revision: 796c8ef79279f9c774545b3771238c3098dbefab
|
||||
- platform: web
|
||||
create_revision: 796c8ef79279f9c774545b3771238c3098dbefab
|
||||
base_revision: 796c8ef79279f9c774545b3771238c3098dbefab
|
||||
create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||
base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||
- platform: windows
|
||||
create_revision: 796c8ef79279f9c774545b3771238c3098dbefab
|
||||
base_revision: 796c8ef79279f9c774545b3771238c3098dbefab
|
||||
create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||
base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2
|
||||
|
||||
# User provided section
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
repos:
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: dart-format
|
||||
name: dart format
|
||||
entry: dart format
|
||||
language: system
|
||||
files: \.dart$
|
||||
- id: flutter-analyze
|
||||
name: flutter analyze (pre-push)
|
||||
entry: flutter analyze --no-fatal-infos
|
||||
language: system
|
||||
pass_filenames: false
|
||||
stages: [pre-push]
|
||||
@@ -0,0 +1,7 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="main.dart" type="FlutterRunConfigurationType" factoryName="Flutter">
|
||||
<option name="additionalArgs" value="--dart-define-from-file env.json" />
|
||||
<option name="filePath" value="$PROJECT_DIR$/lib/main.dart" />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"lsp": {
|
||||
"gopls": {
|
||||
"binary": {
|
||||
"env": {
|
||||
"CGO_ENABLED": "0",
|
||||
// "GOOS": "android",
|
||||
// "GOARCH": "arm64",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file is the entry point for AI coding agents working in this repository. Keep it small: detailed guidance lives under
|
||||
`.agents/`, and discoverable repo skills live under `.agents/skills/*/SKILL.md`.
|
||||
|
||||
## Start Here
|
||||
|
||||
Read these files before making changes:
|
||||
|
||||
- [.agents/project.md](.agents/project.md): project overview, versions, and build dependencies.
|
||||
- [.agents/commands.md](.agents/commands.md): build, development, code generation, and test commands.
|
||||
- [.agents/rules.md](.agents/rules.md): lint, testing, generated-code, and workflow rules.
|
||||
|
||||
Read these only when the task touches their area:
|
||||
|
||||
- [.agents/architecture.md](.agents/architecture.md): core integration, providers, database, managers, build system, and
|
||||
local plugins.
|
||||
- [.agents/agent-config.md](.agents/agent-config.md): how to choose between `AGENTS.md`, `.agents`, skills, Codex config,
|
||||
command rules, and hooks.
|
||||
- [.agents/skills.md](.agents/skills.md): index of repo-scoped skills in `.agents/skills/`.
|
||||
|
||||
## Highest Priority Rules
|
||||
|
||||
- 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. Never annotate line by line; comments belong only at the few key points
|
||||
that cannot be understood without one, and there you must propose the exact text and wait for approval. Delete
|
||||
commented-out code and stale notes whenever you touch the surrounding code. Put assertable behavior in a test,
|
||||
repository-wide invariants in `.agents/`, and keep a comment only for a fact that is local to one call site.
|
||||
See [.agents/rules.md](.agents/rules.md) for the full policy.
|
||||
- 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
|
||||
`flutter test --reporter expanded` when practical.
|
||||
|
||||
## Repo Skills
|
||||
|
||||
Use repo skills from `.agents/skills/` when a task matches their descriptions. Current skills cover localization,
|
||||
provider tests, UI work, and core/platform changes.
|
||||
@@ -1,3 +1,79 @@
|
||||
## v0.8.95
|
||||
|
||||
- Optimize core service
|
||||
|
||||
- Optimize Android TV launcher icon
|
||||
|
||||
- Optimize back navigation
|
||||
|
||||
- Optimize more details
|
||||
|
||||
- Fix some issues
|
||||
|
||||
- Optimize app layout
|
||||
|
||||
- Optimize focus control
|
||||
|
||||
- Adjust android process
|
||||
|
||||
## v0.8.94
|
||||
|
||||
- Fix macos performance issue
|
||||
|
||||
- Support custom global-ua
|
||||
|
||||
- Update core
|
||||
|
||||
- Optimize some details
|
||||
|
||||
- Fix linux silent launching not working
|
||||
|
||||
## v0.8.93
|
||||
|
||||
- Support custom overwrite
|
||||
|
||||
- Support run on demand
|
||||
|
||||
- Optimize windows ipc
|
||||
|
||||
- Optimize windows arm64
|
||||
|
||||
- Optimize build
|
||||
|
||||
- Optimize some details
|
||||
|
||||
- Update core
|
||||
|
||||
## v0.8.92
|
||||
|
||||
- Add sqlite store
|
||||
|
||||
- Optimize android quick action
|
||||
|
||||
- Optimize backup and restore
|
||||
|
||||
- Optimize more details
|
||||
|
||||
## v0.8.91
|
||||
|
||||
- Fix windows some issues
|
||||
|
||||
- Optimize overwrite handle
|
||||
|
||||
- Optimize access control page
|
||||
|
||||
- Optimize some details
|
||||
|
||||
## v0.8.90
|
||||
|
||||
- Fix android tile service
|
||||
|
||||
- Support append system DNS
|
||||
|
||||
- Fix some issues
|
||||
|
||||
- Update changelog
|
||||
|
||||
## v0.8.89
|
||||
|
||||
- Fix some issues
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This repository uses [AGENTS.md](AGENTS.md) as the canonical agent entry point.
|
||||
Read that file first, then follow the `.agents/` references it routes to.
|
||||
@@ -1,10 +1,35 @@
|
||||
android_arm64:
|
||||
dart ./setup.dart android --arch arm64
|
||||
macos_arm64:
|
||||
dart ./setup.dart macos --arch arm64
|
||||
android_app:
|
||||
dart ./setup.dart android
|
||||
android_arm64_core:
|
||||
dart ./setup.dart android --arch arm64 --out core
|
||||
macos_arm64_core:
|
||||
dart ./setup.dart macos --arch arm64 --out core
|
||||
SHELL := /bin/bash
|
||||
|
||||
PLATFORM ?= macos
|
||||
BUILDKIT := plugins/setup/buildkit/run_build_tool.sh
|
||||
ARCH_ARG := $(if $(ARCH),--arch $(ARCH),)
|
||||
TARGET_PLATFORM_ARG := $(if $(TARGET_PLATFORM),--target-platform $(TARGET_PLATFORM),)
|
||||
FORCE_ARG := $(if $(filter 1 true yes,$(FORCE)),--force,)
|
||||
|
||||
.PHONY: help submodules core core-macos core-linux core-windows core-android
|
||||
|
||||
help:
|
||||
@echo 'make core # build macOS core by default'
|
||||
@echo 'make core PLATFORM=linux ARCH=amd64'
|
||||
@echo 'make core-macos ARCH=arm64'
|
||||
@echo 'make core-android ARCH=arm64'
|
||||
@echo 'make core-android TARGET_PLATFORM=android-arm64'
|
||||
@echo 'make core-macos FORCE=1 # bypass setup build cache'
|
||||
|
||||
submodules:
|
||||
git submodule update --init --recursive
|
||||
|
||||
core:
|
||||
bash $(BUILDKIT) $(PLATFORM) $(ARCH_ARG) $(TARGET_PLATFORM_ARG) $(FORCE_ARG)
|
||||
|
||||
core-macos:
|
||||
$(MAKE) core PLATFORM=macos
|
||||
|
||||
core-linux:
|
||||
$(MAKE) core PLATFORM=linux
|
||||
|
||||
core-windows:
|
||||
$(MAKE) core PLATFORM=windows
|
||||
|
||||
core-android:
|
||||
$(MAKE) core PLATFORM=android
|
||||
|
||||
@@ -61,6 +61,13 @@ Support the following actions
|
||||
|
||||
<a href="https://chen08209.github.io/FlClash-fdroid-repo/repo?fingerprint=789D6D32668712EF7672F9E58DEEB15FBD6DCEEC5AE7A4371EA72F2AAE8A12FD"><img alt="Get it on F-Droid" src="snapshots/get-it-on-fdroid.svg" width="200px"/></a> <a href="https://github.com/chen08209/FlClash/releases"><img alt="Get it on GitHub" src="snapshots/get-it-on-github.svg" width="200px"/></a>
|
||||
|
||||
### Homebrew
|
||||
|
||||
```bash
|
||||
brew tap chen08209/tap
|
||||
brew install --cask flclash
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
1. Update submodules
|
||||
@@ -74,46 +81,51 @@ Support the following actions
|
||||
|
||||
- android
|
||||
|
||||
1. Install `Android SDK` , `Android NDK`
|
||||
1. Install `Android SDK`, `Android NDK`
|
||||
|
||||
2. Set `ANDROID_NDK` environment variables
|
||||
|
||||
3. Run Build script
|
||||
|
||||
```bash
|
||||
dart .\setup.dart android
|
||||
```
|
||||
|
||||
- windows
|
||||
|
||||
1. You need a windows client
|
||||
|
||||
2. Install `Gcc`,`Inno Setup`
|
||||
2. Set `ANDROID_NDK` environment variable
|
||||
|
||||
3. Run build script
|
||||
|
||||
```bash
|
||||
dart .\setup.dart windows --arch <arm64 | amd64>
|
||||
dart setup.dart android
|
||||
```
|
||||
|
||||
- windows
|
||||
|
||||
1. Requires a Windows client
|
||||
|
||||
2. Install `GCC`, `Inno Setup`
|
||||
|
||||
3. Run build script
|
||||
|
||||
```bash
|
||||
dart setup.dart windows
|
||||
```
|
||||
|
||||
- linux
|
||||
|
||||
1. You need a linux client
|
||||
1. Requires a Linux client
|
||||
|
||||
2. Run build script
|
||||
2. Dependencies are auto-installed by setup script, or manually:
|
||||
```bash
|
||||
sudo apt-get install -y libayatana-appindicator3-dev libkeybinder-3.0-dev
|
||||
```
|
||||
|
||||
3. Run build script
|
||||
|
||||
```bash
|
||||
dart .\setup.dart linux --arch <arm64 | amd64>
|
||||
dart setup.dart linux
|
||||
```
|
||||
|
||||
- macOS
|
||||
|
||||
1. You need a macOS client
|
||||
1. Requires a macOS client
|
||||
|
||||
2. Run build script
|
||||
|
||||
```bash
|
||||
dart .\setup.dart macos --arch <arm64 | amd64>
|
||||
dart setup.dart macos
|
||||
```
|
||||
|
||||
## Star
|
||||
@@ -124,4 +136,4 @@ The easiest way to support developers is to click on the star (⭐) at the top o
|
||||
<a href="https://api.star-history.com/svg?repos=chen08209/FlClash&Date">
|
||||
<img alt="start" width=50% src="https://api.star-history.com/svg?repos=chen08209/FlClash&Date"/>
|
||||
</a>
|
||||
</p>
|
||||
</p>
|
||||
|
||||
+20
-8
@@ -61,6 +61,13 @@ on Mobile:
|
||||
|
||||
<a href="https://chen08209.github.io/FlClash-fdroid-repo/repo?fingerprint=789D6D32668712EF7672F9E58DEEB15FBD6DCEEC5AE7A4371EA72F2AAE8A12FD"><img alt="Get it on F-Droid" src="snapshots/get-it-on-fdroid.svg" width="200px"/></a> <a href="https://github.com/chen08209/FlClash/releases"><img alt="Get it on GitHub" src="snapshots/get-it-on-github.svg" width="200px"/></a>
|
||||
|
||||
### Homebrew
|
||||
|
||||
```bash
|
||||
brew tap chen08209/tap
|
||||
brew install --cask flclash
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
1. 更新 submodules
|
||||
@@ -81,29 +88,34 @@ on Mobile:
|
||||
3. 运行构建脚本
|
||||
|
||||
```bash
|
||||
dart .\setup.dart android
|
||||
dart setup.dart android
|
||||
```
|
||||
|
||||
- windows
|
||||
|
||||
1. 你需要一个windows客户端
|
||||
|
||||
2. 安装 `Gcc`,`Inno Setup`
|
||||
2. 安装 `GCC`,`Inno Setup`
|
||||
|
||||
3. 运行构建脚本
|
||||
|
||||
```bash
|
||||
dart .\setup.dart windows --arch <arm64 | amd64>
|
||||
dart setup.dart windows
|
||||
```
|
||||
|
||||
- linux
|
||||
|
||||
1. 你需要一个linux客户端
|
||||
|
||||
2. 运行构建脚本
|
||||
2. 依赖会由 setup 脚本自动安装,也可以手动安装:
|
||||
```bash
|
||||
sudo apt-get install -y libayatana-appindicator3-dev libkeybinder-3.0-dev
|
||||
```
|
||||
|
||||
3. 运行构建脚本
|
||||
|
||||
```bash
|
||||
dart .\setup.dart linux --arch <arm64 | amd64>
|
||||
dart setup.dart linux
|
||||
```
|
||||
|
||||
- macOS
|
||||
@@ -113,10 +125,10 @@ on Mobile:
|
||||
2. 运行构建脚本
|
||||
|
||||
```bash
|
||||
dart .\setup.dart macos --arch <arm64 | amd64>
|
||||
dart setup.dart macos
|
||||
```
|
||||
|
||||
## Star History
|
||||
## Star
|
||||
|
||||
支持开发者的最简单方式是点击页面顶部的星标(⭐)。
|
||||
|
||||
@@ -124,4 +136,4 @@ on Mobile:
|
||||
<a href="https://api.star-history.com/svg?repos=chen08209/FlClash&Date">
|
||||
<img alt="start" width=50% src="https://api.star-history.com/svg?repos=chen08209/FlClash&Date"/>
|
||||
</a>
|
||||
</p>
|
||||
</p>
|
||||
|
||||
+34
-1
@@ -1,10 +1,43 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
analyzer:
|
||||
exclude:
|
||||
- build/**
|
||||
- lib/l10n/intl/**
|
||||
- lib/**/generated/**
|
||||
- plugins/**
|
||||
errors:
|
||||
invalid_annotation_target: ignore
|
||||
|
||||
linter:
|
||||
rules:
|
||||
prefer_single_quotes: true
|
||||
# Style
|
||||
prefer_single_quotes: true
|
||||
require_trailing_commas: true
|
||||
sort_child_properties_last: true
|
||||
|
||||
# Avoid unnecessary code
|
||||
unnecessary_new: true
|
||||
unnecessary_this: true
|
||||
unnecessary_const: true
|
||||
avoid_print: true
|
||||
avoid_unnecessary_containers: true
|
||||
sized_box_for_whitespace: true
|
||||
|
||||
# Prefer best practices
|
||||
prefer_const_constructors: true
|
||||
prefer_const_declarations: true
|
||||
prefer_final_locals: true
|
||||
prefer_final_in_for_each: true
|
||||
prefer_is_empty: true
|
||||
prefer_is_not_empty: true
|
||||
prefer_interpolation_to_compose_strings: true
|
||||
|
||||
# Type safety
|
||||
always_declare_return_types: true
|
||||
annotate_overrides: true
|
||||
use_key_in_widget_constructors: true
|
||||
no_leading_underscores_for_local_identifiers: true
|
||||
|
||||
# Async
|
||||
use_build_context_synchronously: true
|
||||
unnecessary_await_in_return: true
|
||||
@@ -3,34 +3,32 @@ import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
id("com.google.gms.google-services")
|
||||
id("com.google.firebase.crashlytics")
|
||||
}
|
||||
|
||||
val localPropertiesFile = rootProject.file("local.properties")
|
||||
val localProperties = Properties().apply {
|
||||
val localPropertiesFile = rootProject.file("local.properties")
|
||||
if (localPropertiesFile.exists()) {
|
||||
localPropertiesFile.inputStream().use { load(it) }
|
||||
}
|
||||
}
|
||||
|
||||
val mStoreFile: File = file("keystore.jks")
|
||||
val mStorePassword: String? = localProperties.getProperty("storePassword")
|
||||
val mKeyAlias: String? = localProperties.getProperty("keyAlias")
|
||||
val mKeyPassword: String? = localProperties.getProperty("keyPassword")
|
||||
val isRelease =
|
||||
mStoreFile.exists() && mStorePassword != null && mKeyAlias != null && mKeyPassword != null
|
||||
|
||||
val releaseStoreFile = file("keystore.jks")
|
||||
val releaseStorePassword = localProperties.getProperty("storePassword")
|
||||
val releaseKeyAlias = localProperties.getProperty("keyAlias")
|
||||
val releaseKeyPassword = localProperties.getProperty("keyPassword")
|
||||
val hasReleaseSigning = releaseStoreFile.exists() &&
|
||||
releaseStorePassword != null &&
|
||||
releaseKeyAlias != null &&
|
||||
releaseKeyPassword != null
|
||||
|
||||
android {
|
||||
namespace = "com.follow.clash"
|
||||
compileSdk = libs.versions.compileSdk.get().toInt()
|
||||
ndkVersion = libs.versions.ndkVersion.get()
|
||||
|
||||
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
@@ -45,12 +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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,20 +62,22 @@ android {
|
||||
buildTypes {
|
||||
debug {
|
||||
isMinifyEnabled = false
|
||||
applicationIdSuffix = ".debug"
|
||||
applicationIdSuffix = ".dev"
|
||||
}
|
||||
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
signingConfig = if (isRelease) {
|
||||
signingConfigs.getByName("release")
|
||||
if (hasReleaseSigning) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
} else {
|
||||
signingConfigs.getByName("debug")
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
applicationIdSuffix = ".dev"
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -105,4 +105,4 @@ dependencies {
|
||||
implementation(platform(libs.firebase.bom))
|
||||
implementation(libs.firebase.crashlytics.ndk)
|
||||
implementation(libs.firebase.analytics)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,25 @@
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:000000000000:android:0000000000000000",
|
||||
"android_client_info": {
|
||||
"package_name": "com.follow.clash.dev"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "0"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+2
-2
@@ -1,4 +1,4 @@
|
||||
|
||||
-keep class com.follow.clash.models.**{ *; }
|
||||
-keep class com.follow.clash.models.** { *; }
|
||||
|
||||
-keep class com.follow.clash.service.models.**{ *; }
|
||||
-keep class com.follow.clash.service.models.** { *; }
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<application
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="FlClash Debug"
|
||||
|
||||
@@ -11,24 +11,28 @@
|
||||
<uses-feature
|
||||
android:name="android.software.leanback"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.wifi"
|
||||
android:required="false" />
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
||||
<uses-permission
|
||||
android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||
tools:ignore="QueryAllPackagesPermission" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
|
||||
<application
|
||||
android:name=".Application"
|
||||
android:name=".FlClashApplication"
|
||||
android:banner="@mipmap/ic_banner"
|
||||
android:hardwareAccelerated="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="FlClash">
|
||||
android:label="@string/app_name">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
@@ -65,7 +69,8 @@
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".TempActivity"
|
||||
android:name=".QuickActionActivity"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="true"
|
||||
android:theme="@style/TransparentTheme">
|
||||
<intent-filter>
|
||||
@@ -86,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" />
|
||||
@@ -97,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>
|
||||
|
||||
@@ -111,4 +116,4 @@
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
</application>
|
||||
</manifest>
|
||||
</manifest>
|
||||
|
||||
@@ -1,27 +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")
|
||||
State.handleStopServiceAction()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +1,56 @@
|
||||
package com.follow.clash
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context.MODE_PRIVATE
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.widget.Toast
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import com.follow.clash.common.GlobalState
|
||||
import com.follow.clash.models.SharedState
|
||||
import com.google.gson.Gson
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.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 preferences = getSharedPreferences("FlutterSharedPreferences", MODE_PRIVATE)
|
||||
val json = preferences.getString("flutter.sharedState", null)
|
||||
Gson().fromJson(json, SharedState::class.java) ?: SharedState()
|
||||
} catch (_: Exception) {
|
||||
SharedState()
|
||||
}
|
||||
|
||||
private var lastToast: Toast? = null
|
||||
|
||||
fun Application.showToast(text: String?) {
|
||||
if (text.isNullOrEmpty()) return
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
lastToast?.cancel()
|
||||
lastToast = Toast.makeText(this, text, Toast.LENGTH_LONG).apply {
|
||||
show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun PackageManager.getPackageIconPath(packageName: String): String =
|
||||
withContext(Dispatchers.IO) {
|
||||
val cacheDir = GlobalState.application.cacheDir
|
||||
val iconDir = File(cacheDir, "icons").apply { mkdirs() }
|
||||
return@withContext try {
|
||||
val 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
|
||||
@@ -51,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)
|
||||
@@ -74,48 +94,12 @@ private fun isExpired(file: File): Boolean {
|
||||
return age > TimeUnit.DAYS.toMillis(ICON_TTL_DAYS)
|
||||
}
|
||||
|
||||
suspend fun <T> MethodChannel.awaitResult(
|
||||
method: String, arguments: Any? = null
|
||||
): T? = withContext(Dispatchers.Main) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
invokeMethod(method, arguments, object : MethodChannel.Result {
|
||||
override fun success(result: Any?) {
|
||||
@Suppress("UNCHECKED_CAST") continuation.resume(result as T?)
|
||||
}
|
||||
|
||||
override fun error(code: String, message: String?, details: Any?) {
|
||||
continuation.resume(null)
|
||||
}
|
||||
|
||||
override fun notImplemented() {
|
||||
continuation.resume(null)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <reified T : FlutterPlugin> FlutterEngine.plugin(): T? {
|
||||
return plugins.get(T::class.java) as T?
|
||||
}
|
||||
|
||||
fun <T> MethodChannel.invokeMethodOnMainThread(
|
||||
method: String, arguments: Any? = null, callback: ((Result<T>) -> Unit)? = null
|
||||
) {
|
||||
fun MethodChannel.invokeMethodOnMainThread(method: String, arguments: Any? = null) {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
invokeMethod(method, arguments, object : MethodChannel.Result {
|
||||
override fun success(result: Any?) {
|
||||
@Suppress("UNCHECKED_CAST") callback?.invoke(Result.success(result as T))
|
||||
}
|
||||
|
||||
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
|
||||
val exception = Exception("MethodChannel error: $errorCode - $errorMessage")
|
||||
callback?.invoke(Result.failure(exception))
|
||||
}
|
||||
|
||||
override fun notImplemented() {
|
||||
val exception = NotImplementedError("Method not implemented: $method")
|
||||
callback?.invoke(Result.failure(exception))
|
||||
}
|
||||
})
|
||||
invokeMethod(method, arguments)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -4,8 +4,7 @@ import android.app.Application
|
||||
import android.content.Context
|
||||
import com.follow.clash.common.GlobalState
|
||||
|
||||
class Application : Application() {
|
||||
|
||||
class FlClashApplication : Application() {
|
||||
override fun attachBaseContext(base: Context?) {
|
||||
super.attachBaseContext(base)
|
||||
GlobalState.init(this)
|
||||
@@ -1,41 +1,22 @@
|
||||
package com.follow.clash
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
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)
|
||||
lifecycleScope.launch {
|
||||
State.destroyServiceEngine()
|
||||
}
|
||||
}
|
||||
|
||||
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,147 +0,0 @@
|
||||
package com.follow.clash
|
||||
|
||||
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.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(res.formatString())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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,161 +0,0 @@
|
||||
package com.follow.clash
|
||||
|
||||
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.FlutterInjector
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.embedding.engine.dart.DartExecutor
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
enum class RunState {
|
||||
START, PENDING, STOP
|
||||
}
|
||||
|
||||
|
||||
object State {
|
||||
|
||||
val runLock = Mutex()
|
||||
|
||||
var runTime: Long = 0
|
||||
|
||||
val runStateFlow: MutableStateFlow<RunState> = MutableStateFlow(RunState.STOP)
|
||||
|
||||
var flutterEngine: FlutterEngine? = null
|
||||
var serviceFlutterEngine: FlutterEngine? = null
|
||||
|
||||
val appPlugin: AppPlugin?
|
||||
get() = flutterEngine?.plugin<AppPlugin>() ?: serviceFlutterEngine?.plugin<AppPlugin>()
|
||||
|
||||
val servicePlugin: ServicePlugin?
|
||||
get() = flutterEngine?.plugin<ServicePlugin>()
|
||||
?: serviceFlutterEngine?.plugin<ServicePlugin>()
|
||||
|
||||
val tilePlugin: TilePlugin?
|
||||
get() = flutterEngine?.plugin<TilePlugin>() ?: serviceFlutterEngine?.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 {
|
||||
Service.bind()
|
||||
runTime = Service.getRunTime()
|
||||
val runState = when (runTime == 0L) {
|
||||
true -> RunState.STOP
|
||||
false -> RunState.START
|
||||
}
|
||||
runStateFlow.tryEmit(runState)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun handleStartServiceAction() {
|
||||
tilePlugin?.handleStart()
|
||||
if (flutterEngine != null) {
|
||||
return
|
||||
}
|
||||
startServiceWithEngine()
|
||||
}
|
||||
|
||||
fun handleStopServiceAction() {
|
||||
tilePlugin?.handleStop()
|
||||
if (flutterEngine != null || serviceFlutterEngine != null) {
|
||||
return
|
||||
}
|
||||
handleStopService()
|
||||
}
|
||||
|
||||
fun handleStartService() {
|
||||
if (appPlugin != null) {
|
||||
appPlugin?.requestNotificationsPermission {
|
||||
startService()
|
||||
}
|
||||
return
|
||||
}
|
||||
startService()
|
||||
}
|
||||
|
||||
suspend fun destroyServiceEngine() {
|
||||
runLock.withLock {
|
||||
withContext(Dispatchers.Main) {
|
||||
runCatching {
|
||||
serviceFlutterEngine?.destroy()
|
||||
serviceFlutterEngine = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun startServiceWithEngine() {
|
||||
runLock.withLock {
|
||||
if (serviceFlutterEngine != null || runStateFlow.value == RunState.PENDING || runStateFlow.value == RunState.START) {
|
||||
return
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
serviceFlutterEngine = FlutterEngine(GlobalState.application)
|
||||
serviceFlutterEngine?.plugins?.add(ServicePlugin())
|
||||
serviceFlutterEngine?.plugins?.add(AppPlugin())
|
||||
serviceFlutterEngine?.plugins?.add(TilePlugin())
|
||||
val dartEntrypoint = DartExecutor.DartEntrypoint(
|
||||
FlutterInjector.instance().flutterLoader().findAppBundlePath(), "_service"
|
||||
)
|
||||
serviceFlutterEngine?.dartExecutor?.executeDartEntrypoint(dartEntrypoint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startService() {
|
||||
GlobalState.launch {
|
||||
runLock.withLock {
|
||||
if (runStateFlow.value == RunState.PENDING || runStateFlow.value == RunState.START) {
|
||||
return@launch
|
||||
}
|
||||
runStateFlow.tryEmit(RunState.PENDING)
|
||||
if (servicePlugin == null) {
|
||||
return@launch
|
||||
}
|
||||
val options = servicePlugin?.handleGetVpnOptions()
|
||||
if (options == null) {
|
||||
return@launch
|
||||
}
|
||||
appPlugin?.prepare(options.enable) {
|
||||
runTime = Service.startService(options, runTime)
|
||||
runStateFlow.tryEmit(RunState.START)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun handleStopService() {
|
||||
GlobalState.launch {
|
||||
runLock.withLock {
|
||||
if (runStateFlow.value == RunState.PENDING || runStateFlow.value == RunState.STOP) {
|
||||
return@launch
|
||||
}
|
||||
runStateFlow.tryEmit(RunState.PENDING)
|
||||
runTime = Service.stopService()
|
||||
runStateFlow.tryEmit(RunState.STOP)
|
||||
}
|
||||
destroyServiceEngine()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,35 +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 -> {
|
||||
State.handleStopServiceAction()
|
||||
}
|
||||
|
||||
QuickAction.TOGGLE.action -> {
|
||||
launch {
|
||||
State.handleToggleAction()
|
||||
}
|
||||
}
|
||||
}
|
||||
finishAndRemoveTask()
|
||||
}
|
||||
}
|
||||
@@ -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
-1
@@ -1,6 +1,6 @@
|
||||
package com.follow.clash.models
|
||||
|
||||
data class Package(
|
||||
data class InstalledPackage(
|
||||
val packageName: String,
|
||||
val label: String,
|
||||
val system: Boolean,
|
||||
@@ -1,9 +1,22 @@
|
||||
package com.follow.clash.models
|
||||
|
||||
import com.follow.clash.service.models.VpnOptions
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class AppState(
|
||||
data class SharedState(
|
||||
val startTip: String = "Starting VPN...",
|
||||
val stopTip: String = "Stopping VPN...",
|
||||
val crashlytics: Boolean = true,
|
||||
val currentProfileName: String = "FlClash",
|
||||
val stopText: String = "Stop",
|
||||
val onlyStatisticsProxy: Boolean = false,
|
||||
val vpnOptions: VpnOptions? = null,
|
||||
val setupParams: SetupParams? = null,
|
||||
)
|
||||
|
||||
data class SetupParams(
|
||||
@SerializedName("test-url")
|
||||
val testUrl: String,
|
||||
@SerializedName("selected-map")
|
||||
val selectedMap: Map<String, String>,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
package com.follow.clash.packages
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.ComponentInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import com.android.tools.smali.dexlib2.dexbacked.DexBackedDexFile
|
||||
import com.follow.clash.models.InstalledPackage
|
||||
import java.io.File
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
internal class PackageResolver(
|
||||
private val packageManager: PackageManager,
|
||||
private val appPackageName: String,
|
||||
) {
|
||||
val installedPackages: List<InstalledPackage> by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
|
||||
loadPackages()
|
||||
}
|
||||
|
||||
fun getChinaPackageNames(): List<String> = installedPackages
|
||||
.map { it.packageName }
|
||||
.filter(::isChinaPackage)
|
||||
|
||||
private fun loadPackages(): List<InstalledPackage> {
|
||||
val flags = PackageManager.GET_PERMISSIONS
|
||||
val packages = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
packageManager.getInstalledPackages(
|
||||
PackageManager.PackageInfoFlags.of(flags.toLong()),
|
||||
)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
packageManager.getInstalledPackages(flags)
|
||||
}
|
||||
return packages.asSequence()
|
||||
.filter { info ->
|
||||
info.packageName != appPackageName && info.packageName != ANDROID_PACKAGE_NAME
|
||||
}
|
||||
.map { info ->
|
||||
InstalledPackage(
|
||||
packageName = info.packageName,
|
||||
label = info.applicationInfo?.loadLabel(packageManager)?.toString()
|
||||
?: info.packageName,
|
||||
system = info.applicationInfo?.let { applicationInfo ->
|
||||
applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM != 0
|
||||
} == true,
|
||||
internet = info.requestedPermissions
|
||||
?.contains(Manifest.permission.INTERNET) == true,
|
||||
lastUpdateTime = info.lastUpdateTime,
|
||||
)
|
||||
}.toList()
|
||||
}
|
||||
|
||||
private fun isChinaPackage(packageName: String): Boolean {
|
||||
if (SKIPPED_PREFIXES.any { packageName == it || packageName.startsWith("$it.") }) {
|
||||
return false
|
||||
}
|
||||
if (packageName.matches(CHINA_PACKAGE_REGEX)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return runCatching {
|
||||
val packageInfo = getPackageInfo(packageName)
|
||||
packageInfo.componentNames().any { it.matches(CHINA_PACKAGE_REGEX) } ||
|
||||
packageInfo.applicationInfo?.publicSourceDir?.let(::scanArchive) == true
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
private fun getPackageInfo(packageName: String) = if (
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
|
||||
) {
|
||||
packageManager.getPackageInfo(
|
||||
packageName,
|
||||
PackageManager.PackageInfoFlags.of(PACKAGE_INFO_FLAGS.toLong()),
|
||||
)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
packageManager.getPackageInfo(packageName, PACKAGE_INFO_FLAGS)
|
||||
}
|
||||
|
||||
private fun android.content.pm.PackageInfo.componentNames(): Sequence<String> = sequence {
|
||||
yieldAll(services.orEmpty().asSequence().map(ComponentInfo::name))
|
||||
yieldAll(activities.orEmpty().asSequence().map(ComponentInfo::name))
|
||||
yieldAll(receivers.orEmpty().asSequence().map(ComponentInfo::name))
|
||||
yieldAll(providers.orEmpty().asSequence().map(ComponentInfo::name))
|
||||
}
|
||||
|
||||
private fun scanArchive(sourcePath: String): Boolean = ZipFile(File(sourcePath)).use { archive ->
|
||||
if (archive.entries().asSequence().any { it.name.startsWith("firebase-") }) {
|
||||
return false
|
||||
}
|
||||
archive.entries().asSequence()
|
||||
.filter { entry ->
|
||||
entry.name.startsWith("classes") && entry.name.endsWith(".dex")
|
||||
}.any { entry ->
|
||||
if (entry.size > MAX_DEX_SIZE_BYTES) {
|
||||
return@any true
|
||||
}
|
||||
val dexFile = archive.getInputStream(entry).buffered().use { input ->
|
||||
DexBackedDexFile.fromInputStream(null, input)
|
||||
}
|
||||
dexFile.classes.any { clazz ->
|
||||
clazz.type
|
||||
.removeSurrounding("L", ";")
|
||||
.replace('/', '.')
|
||||
.replace('$', '.')
|
||||
.matches(CHINA_PACKAGE_REGEX)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val ANDROID_PACKAGE_NAME = "android"
|
||||
const val MAX_DEX_SIZE_BYTES = 15_000_000L
|
||||
|
||||
val PACKAGE_INFO_FLAGS = PackageManager.GET_ACTIVITIES or
|
||||
PackageManager.GET_SERVICES or
|
||||
PackageManager.GET_RECEIVERS or
|
||||
PackageManager.GET_PROVIDERS
|
||||
|
||||
val SKIPPED_PREFIXES = listOf(
|
||||
"com.google",
|
||||
"com.android.chrome",
|
||||
"com.android.vending",
|
||||
"com.microsoft",
|
||||
"com.apple",
|
||||
"com.zhiliaoapp.musically",
|
||||
)
|
||||
|
||||
val CHINA_PACKAGE_REGEX = listOf(
|
||||
"com.tencent",
|
||||
"com.alibaba",
|
||||
"com.umeng",
|
||||
"com.qihoo",
|
||||
"com.ali",
|
||||
"com.alipay",
|
||||
"com.amap",
|
||||
"com.sina",
|
||||
"com.weibo",
|
||||
"com.vivo",
|
||||
"com.xiaomi",
|
||||
"com.huawei",
|
||||
"com.taobao",
|
||||
"com.secneo",
|
||||
"s.h.e.l.l",
|
||||
"com.stub",
|
||||
"com.kiwisec",
|
||||
"com.secshell",
|
||||
"com.wrapper",
|
||||
"cn.securitystack",
|
||||
"com.mogosec",
|
||||
"com.secoen",
|
||||
"com.netease",
|
||||
"com.mx",
|
||||
"com.qq.e",
|
||||
"com.baidu",
|
||||
"com.bytedance",
|
||||
"com.bugly",
|
||||
"com.miui",
|
||||
"com.oppo",
|
||||
"com.coloros",
|
||||
"com.iqoo",
|
||||
"com.meizu",
|
||||
"com.gionee",
|
||||
"cn.nubia",
|
||||
"com.oplus",
|
||||
"andes.oplus",
|
||||
"com.unionpay",
|
||||
"cn.wps",
|
||||
).joinToString("|", prefix = "(", postfix = ").*") { prefix ->
|
||||
Regex.escape(prefix)
|
||||
}.toRegex()
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,31 @@
|
||||
package com.follow.clash.plugins
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.app.ActivityManager
|
||||
import android.content.Intent
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.ComponentInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.VpnService
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.ContextCompat.getSystemService
|
||||
import androidx.core.content.pm.ShortcutInfoCompat
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import com.android.tools.smali.dexlib2.dexbacked.DexBackedDexFile
|
||||
import androidx.core.net.toUri
|
||||
import com.follow.clash.R
|
||||
import com.follow.clash.common.Components
|
||||
import com.follow.clash.common.GlobalState
|
||||
import com.follow.clash.common.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
|
||||
@@ -34,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)
|
||||
}
|
||||
|
||||
@@ -131,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()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,10 +104,26 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
||||
|
||||
"tip" -> {
|
||||
val message = call.argument<String>("message")
|
||||
tip(message)
|
||||
GlobalState.application.showToast(message)
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
"isBatteryOptimizationDisabled" -> {
|
||||
result.success(isBatteryOptimizationDisabled())
|
||||
}
|
||||
|
||||
"openBatteryOptimizationSettings" -> {
|
||||
result.success(openBatteryOptimizationSettings())
|
||||
}
|
||||
|
||||
"openAppSettings" -> {
|
||||
result.success(openAppSettings())
|
||||
}
|
||||
|
||||
"didCrashOnPreviousExecution" -> {
|
||||
result.success(GlobalState.didCrashOnPreviousExecution())
|
||||
}
|
||||
|
||||
else -> {
|
||||
result.notImplemented()
|
||||
}
|
||||
@@ -182,18 +149,49 @@ 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?) {
|
||||
Toast.makeText(GlobalState.application, message, Toast.LENGTH_LONG).show()
|
||||
private fun isBatteryOptimizationDisabled(): Boolean {
|
||||
val powerManager = getSystemService(GlobalState.application, PowerManager::class.java)
|
||||
return powerManager?.isIgnoringBatteryOptimizations(GlobalState.application.packageName)
|
||||
?: false
|
||||
}
|
||||
|
||||
@SuppressLint("BatteryLife")
|
||||
private fun openBatteryOptimizationSettings(): Boolean {
|
||||
// VPN continuity is the user-requested core function, so the direct exemption is intentional.
|
||||
val activity = activity ?: return false
|
||||
return try {
|
||||
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
|
||||
data = "package:${GlobalState.application.packageName}".toUri()
|
||||
}
|
||||
activity.startActivity(intent)
|
||||
true
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun openAppSettings(): Boolean {
|
||||
val activity = activity ?: return false
|
||||
return try {
|
||||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = "package:${GlobalState.application.packageName}".toUri()
|
||||
}
|
||||
activity.startActivity(intent)
|
||||
true
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
@@ -201,173 +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 = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
PackageManager.MATCH_UNINSTALLED_PACKAGES or PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES or PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS
|
||||
} else {
|
||||
PackageManager.GET_UNINSTALLED_PACKAGES or PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES or PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS
|
||||
}
|
||||
if (packageName.matches(chinaAppRegex)) {
|
||||
return true
|
||||
}
|
||||
try {
|
||||
val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
packageManager.getPackageInfo(
|
||||
packageName, PackageManager.PackageInfoFlags.of(packageManagerFlags.toLong())
|
||||
)
|
||||
} 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)
|
||||
@@ -376,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,15 +1,9 @@
|
||||
package com.follow.clash.plugins
|
||||
|
||||
import com.follow.clash.RunState
|
||||
import com.follow.clash.Service
|
||||
import com.follow.clash.State
|
||||
import com.follow.clash.awaitResult
|
||||
import com.follow.clash.ServiceController
|
||||
import com.follow.clash.ServiceState
|
||||
import com.follow.clash.common.Components
|
||||
import com.follow.clash.common.GlobalState
|
||||
import com.follow.clash.invokeMethodOnMainThread
|
||||
import com.follow.clash.models.AppState
|
||||
import com.follow.clash.service.models.NotificationParams
|
||||
import com.follow.clash.service.models.VpnOptions
|
||||
import com.follow.clash.models.SharedState
|
||||
import com.google.gson.Gson
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
@@ -17,144 +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(call, 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)
|
||||
}
|
||||
|
||||
suspend fun handleGetVpnOptions(): VpnOptions? {
|
||||
val res = flutterMethodChannel.awaitResult<String>("getVpnOptions", null)
|
||||
return Gson().fromJson(res, VpnOptions::class.java)
|
||||
}
|
||||
|
||||
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>()!!
|
||||
val params = Gson().fromJson(data, AppState::class.java)
|
||||
GlobalState.setCrashlytics(params.crashlytics)
|
||||
launch {
|
||||
Service.updateNotificationParams(
|
||||
NotificationParams(
|
||||
title = params.currentProfileName,
|
||||
stopText = params.stopText,
|
||||
onlyStatisticsProxy = params.onlyStatisticsProxy
|
||||
)
|
||||
)
|
||||
Service.setCrashlytics(params.crashlytics)
|
||||
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(call: MethodCall, result: MethodChannel.Result) {
|
||||
Service.bind()
|
||||
launch {
|
||||
val needSetEventListener = call.arguments<Boolean>() ?: false
|
||||
when (needSetEventListener) {
|
||||
true -> Service.setEventListener {
|
||||
handleSendEvent(it)
|
||||
}
|
||||
|
||||
false -> Service.setEventListener(null)
|
||||
}.onSuccess {
|
||||
result.success("")
|
||||
}.onFailure {
|
||||
result.success(it.message)
|
||||
}
|
||||
|
||||
}
|
||||
Service.onServiceDisconnected = ::onServiceDisconnected
|
||||
private fun start(result: MethodChannel.Result) {
|
||||
ServiceState.requestStart()
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
private fun handleGetRunTime(result: MethodChannel.Result) {
|
||||
launch {
|
||||
State.handleSyncState()
|
||||
result.success(State.runTime)
|
||||
private fun stop(result: MethodChannel.Result) {
|
||||
ServiceState.requestStop()
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
private fun sendEvent(value: String?) {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
channel.invokeMethod("event", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
class TilePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||
|
||||
private lateinit var channel: MethodChannel
|
||||
|
||||
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
|
||||
@@ -21,13 +20,14 @@ class TilePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||
}
|
||||
|
||||
fun handleStart() {
|
||||
channel.invokeMethodOnMainThread<Any>("start", null)
|
||||
channel.invokeMethodOnMainThread("start")
|
||||
}
|
||||
|
||||
fun handleStop() {
|
||||
channel.invokeMethodOnMainThread<Any>("stop", null)
|
||||
channel.invokeMethodOnMainThread("stop")
|
||||
}
|
||||
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {}
|
||||
}
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="240"
|
||||
android:viewportHeight="240">
|
||||
<group android:scaleX="0.924"
|
||||
android:scaleY="0.924"
|
||||
android:translateX="9.12"
|
||||
android:translateY="9.12">
|
||||
<group android:scaleX="0.63461536"
|
||||
android:scaleY="0.63461536"
|
||||
android:translateX="45.96154"
|
||||
android:translateY="43.846153">
|
||||
<path
|
||||
android:pathData="M60.65,89.6L154.18,35.6A18,18 107.59,0 1,178.77 42.19L178.77,42.19A18,18 107.59,0 1,172.18 66.78L78.65,120.78A18,18 106.67,0 1,54.06 114.19L54.06,114.19A18,18 106.67,0 1,60.65 89.6z"
|
||||
android:fillColor="#6666FB"/>
|
||||
<path
|
||||
android:pathData="M84.65,131.17L131.42,104.17A18,18 107.83,0 1,156 110.76L156,110.76A18,18 107.83,0 1,149.42 135.35L102.65,162.35A18,18 106.67,0 1,78.06 155.76L78.06,155.76A18,18 106.67,0 1,84.65 131.17z"
|
||||
android:fillColor="#336AB6"/>
|
||||
<path
|
||||
android:pathData="M108.65,172.74L108.65,172.74A18,18 116.03,0 1,133.24 179.33L133.24,179.33A18,18 116.03,0 1,126.65 203.92L126.65,203.92A18,18 116.03,0 1,102.06 197.33L102.06,197.33A18,18 116.03,0 1,108.65 172.74z"
|
||||
android:fillColor="#5CA8E9"/>
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="240"
|
||||
android:viewportHeight="240">
|
||||
<group android:scaleX="0.924"
|
||||
android:scaleY="0.924"
|
||||
android:translateX="9.12"
|
||||
android:translateY="9.12">
|
||||
<group android:scaleX="0.63461536"
|
||||
android:scaleY="0.63461536"
|
||||
android:translateX="45.96154"
|
||||
android:translateY="43.846153">
|
||||
<path
|
||||
android:pathData="M60.65,89.6L154.18,35.6A18,18 107.59,0 1,178.77 42.19L178.77,42.19A18,18 107.59,0 1,172.18 66.78L78.65,120.78A18,18 106.67,0 1,54.06 114.19L54.06,114.19A18,18 106.67,0 1,60.65 89.6z"
|
||||
android:fillColor="#6666FB"/>
|
||||
<path
|
||||
android:pathData="M84.65,131.17L131.42,104.17A18,18 107.83,0 1,156 110.76L156,110.76A18,18 107.83,0 1,149.42 135.35L102.65,162.35A18,18 106.67,0 1,78.06 155.76L78.06,155.76A18,18 106.67,0 1,84.65 131.17z"
|
||||
android:fillColor="#336AB6"/>
|
||||
<path
|
||||
android:pathData="M108.65,172.74L108.65,172.74A18,18 116.03,0 1,133.24 179.33L133.24,179.33A18,18 116.03,0 1,126.65 203.92L126.65,203.92A18,18 116.03,0 1,102.06 197.33L102.06,197.33A18,18 116.03,0 1,108.65 172.74z"
|
||||
android:fillColor="#5CA8E9"/>
|
||||
</group>
|
||||
</group>
|
||||
</group>
|
||||
</vector>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="240"
|
||||
android:viewportHeight="240">
|
||||
<group
|
||||
android:scaleX="0.9"
|
||||
android:scaleY="0.9"
|
||||
android:translateX="15.23"
|
||||
android:translateY="12.22">
|
||||
<path
|
||||
android:fillColor="#6666FB"
|
||||
android:pathData="M60.65,89.6L154.18,35.6A18,18 107.59,0 1,178.77 42.19L178.77,42.19A18,18 107.59,0 1,172.18 66.78L78.65,120.78A18,18 106.67,0 1,54.06 114.19L54.06,114.19A18,18 106.67,0 1,60.65 89.6z" />
|
||||
<path
|
||||
android:fillColor="#336AB6"
|
||||
android:pathData="M84.65,131.17L131.42,104.17A18,18 107.83,0 1,156 110.76L156,110.76A18,18 107.83,0 1,149.42 135.35L102.65,162.35A18,18 106.67,0 1,78.06 155.76L78.06,155.76A18,18 106.67,0 1,84.65 131.17z" />
|
||||
<path
|
||||
android:fillColor="#5CA8E9"
|
||||
android:pathData="M108.65,172.74L108.65,172.74A18,18 116.03,0 1,133.24 179.33L133.24,179.33A18,18 116.03,0 1,126.65 203.92L126.65,203.92A18,18 116.03,0 1,102.06 197.33L102.06,197.33A18,18 116.03,0 1,108.65 172.74z" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground_tv" />
|
||||
</adaptive-icon>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 782 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
@@ -1,6 +0,0 @@
|
||||
<paths>
|
||||
<files-path
|
||||
name="files"
|
||||
path="."/>
|
||||
</paths>
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config xmlns:tools="http://schemas.android.com/tools"
|
||||
tools:ignore="AcceptsUserCertificates">
|
||||
<base-config>
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
<certificates src="user" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="true">localhost</domain>
|
||||
<domain includeSubdomains="true">127.0.0.1</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
@@ -1,7 +0,0 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
+15
-13
@@ -1,19 +1,10 @@
|
||||
buildscript {
|
||||
dependencies {
|
||||
classpath(libs.build.kotlin)
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("com.android.library") apply false
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
val newBuildDir: Directory =
|
||||
rootProject.layout.buildDirectory
|
||||
.dir("../../build")
|
||||
@@ -23,12 +14,23 @@ rootProject.layout.buildDirectory.value(newBuildDir)
|
||||
subprojects {
|
||||
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||
}
|
||||
subprojects {
|
||||
project.evaluationDependsOn(":app")
|
||||
}
|
||||
|
||||
gradle.projectsEvaluated {
|
||||
allprojects {
|
||||
tasks.withType<JavaCompile>().configureEach {
|
||||
sourceCompatibility = JavaVersion.VERSION_17.toString()
|
||||
targetCompatibility = JavaVersion.VERSION_17.toString()
|
||||
}
|
||||
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register<Delete>("clean") {
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
@@ -10,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 {
|
||||
@@ -42,4 +30,4 @@ dependencies {
|
||||
implementation(platform(libs.firebase.bom))
|
||||
implementation(libs.firebase.crashlytics.ndk)
|
||||
implementation(libs.firebase.analytics)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2,7 +2,6 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
@@ -14,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")
|
||||
@@ -33,14 +25,6 @@ android {
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
@@ -49,33 +33,6 @@ kotlin {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
dependencies {
|
||||
implementation(libs.annotation.jvm)
|
||||
}
|
||||
|
||||
val copyNativeLibs by tasks.register<Copy>("copyNativeLibs") {
|
||||
doFirst {
|
||||
delete("src/main/jniLibs")
|
||||
}
|
||||
from("../../libclash/android")
|
||||
into("src/main/jniLibs")
|
||||
|
||||
doLast {
|
||||
val includesDir = file("src/main/jniLibs/includes")
|
||||
val targetDir = file("src/main/cpp/includes")
|
||||
if (includesDir.exists()) {
|
||||
copy {
|
||||
from(includesDir)
|
||||
into(targetDir)
|
||||
}
|
||||
delete(includesDir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
afterEvaluate {
|
||||
tasks.named("preBuild") {
|
||||
dependsOn(copyNativeLibs)
|
||||
}
|
||||
}
|
||||
@@ -8,34 +8,21 @@ message("CMAKE_BUILD_TYPE ${CMAKE_BUILD_TYPE}")
|
||||
|
||||
|
||||
if (NOT "${CMAKE_BUILD_TYPE}" STREQUAL "Debug")
|
||||
# set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
|
||||
add_compile_options(-O3)
|
||||
|
||||
add_compile_options(-flto)
|
||||
|
||||
add_compile_options(-g0)
|
||||
|
||||
add_compile_options(-ffunction-sections -fdata-sections)
|
||||
|
||||
add_compile_options(-fno-exceptions -fno-rtti)
|
||||
|
||||
add_link_options(
|
||||
-flto
|
||||
-Wl,--gc-sections
|
||||
-Wl,--strip-all
|
||||
-Wl,--exclude-libs=ALL
|
||||
)
|
||||
|
||||
add_compile_options(-fvisibility=hidden -fvisibility-inlines-hidden)
|
||||
set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
|
||||
add_compile_options(-O3 -flto -g0 -fno-exceptions -fno-rtti)
|
||||
add_link_options(-flto -Wl,--gc-sections,--strip-all)
|
||||
endif ()
|
||||
|
||||
set(LIB_CLASH_PATH "${CMAKE_SOURCE_DIR}/../jniLibs/${ANDROID_ABI}/libclash.so")
|
||||
set(LIB_CLASH_INCLUDE_DIR "${CMAKE_SOURCE_DIR}/../cpp/includes/${ANDROID_ABI}")
|
||||
set(LIB_CLASH_HEADER_PATH "${LIB_CLASH_INCLUDE_DIR}/libclash.h")
|
||||
set(BRIDE_HEADER_PATH "${LIB_CLASH_INCLUDE_DIR}/bride.h")
|
||||
|
||||
message("LIB_CLASH_PATH ${LIB_CLASH_PATH}")
|
||||
if (EXISTS ${LIB_CLASH_PATH})
|
||||
message("Found libclash.so for ABI ${ANDROID_ABI}")
|
||||
if (EXISTS ${LIB_CLASH_PATH} AND EXISTS ${LIB_CLASH_HEADER_PATH} AND EXISTS ${BRIDE_HEADER_PATH})
|
||||
message("Found libclash.so and headers for ABI ${ANDROID_ABI}")
|
||||
add_compile_definitions(LIBCLASH)
|
||||
include_directories(${CMAKE_SOURCE_DIR}/../cpp/includes/${ANDROID_ABI})
|
||||
include_directories(${LIB_CLASH_INCLUDE_DIR})
|
||||
link_directories(${CMAKE_SOURCE_DIR}/../jniLibs/${ANDROID_ABI})
|
||||
add_library(${CMAKE_PROJECT_NAME} SHARED
|
||||
jni_helper.cpp
|
||||
@@ -43,9 +30,9 @@ if (EXISTS ${LIB_CLASH_PATH})
|
||||
target_link_libraries(${CMAKE_PROJECT_NAME}
|
||||
clash)
|
||||
else ()
|
||||
message("Not found libclash.so for ABI ${ANDROID_ABI}")
|
||||
message("Not found complete libclash output for ABI ${ANDROID_ABI}; building core without LIBCLASH")
|
||||
add_library(${CMAKE_PROJECT_NAME} SHARED
|
||||
jni_helper.cpp
|
||||
core.cpp)
|
||||
target_link_libraries(${CMAKE_PROJECT_NAME})
|
||||
endif ()
|
||||
endif ()
|
||||
|
||||
@@ -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"
|
||||
@@ -70,6 +72,14 @@ Java_com_follow_clash_core_Core_suspended(JNIEnv *env, jobject thiz, jboolean su
|
||||
suspend(suspended);
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_follow_clash_core_Core_quickSetup(JNIEnv *env, jobject thiz, jstring init_params_string,
|
||||
jstring setup_params_string, jobject cb) {
|
||||
const auto interface = new_global(cb);
|
||||
quickSetup(interface, get_string(init_params_string), get_string(setup_params_string));
|
||||
}
|
||||
|
||||
|
||||
static jmethodID m_tun_interface_protect;
|
||||
static jmethodID m_tun_interface_resolve_process;
|
||||
@@ -98,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(
|
||||
static_cast<jobject>(tun_interface),
|
||||
m_tun_interface_resolve_process,
|
||||
protocol,
|
||||
new_string(source),
|
||||
new_string(target),
|
||||
uid));
|
||||
return get_string(packageName);
|
||||
const auto source_string = new_string(source);
|
||||
const auto target_string = new_string(target);
|
||||
const auto package_name = reinterpret_cast<jstring>(env->CallObjectMethod(
|
||||
static_cast<jobject>(tun_interface),
|
||||
m_tun_interface_resolve_process,
|
||||
protocol,
|
||||
source_string,
|
||||
target_string,
|
||||
uid));
|
||||
env->DeleteLocalRef(source_string);
|
||||
env->DeleteLocalRef(target_string);
|
||||
const auto result = get_string(package_name);
|
||||
if (package_name != nullptr) {
|
||||
env->DeleteLocalRef(package_name);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static void call_invoke_interface_result_impl(void *invoke_interface, const char *data) {
|
||||
ATTACH_JNI();
|
||||
const auto value = new_string(data);
|
||||
env->CallVoidMethod(static_cast<jobject>(invoke_interface),
|
||||
m_invoke_interface_result,
|
||||
new_string(data));
|
||||
value);
|
||||
env->DeleteLocalRef(value);
|
||||
}
|
||||
|
||||
extern "C"
|
||||
@@ -158,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"
|
||||
@@ -180,15 +200,23 @@ extern "C"
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_com_follow_clash_core_Core_getTraffic(JNIEnv *env, jobject thiz,
|
||||
const jboolean only_statistics_proxy) {
|
||||
return env->NewStringUTF("{}");
|
||||
}
|
||||
extern "C"
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_com_follow_clash_core_Core_getTotalTraffic(JNIEnv *env, jobject thiz,
|
||||
const jboolean only_statistics_proxy) {
|
||||
return env->NewStringUTF("{}");
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_follow_clash_core_Core_suspended(JNIEnv *env, jobject thiz, jboolean suspended) {
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_follow_clash_core_Core_quickSetup(JNIEnv *env, jobject thiz, jstring init_params_string,
|
||||
jstring setup_params_string, jobject cb) {
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,22 +86,44 @@ 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,
|
||||
callback: (result: String?) -> Unit,
|
||||
) {
|
||||
quickSetup(
|
||||
initParamsString,
|
||||
setupParamsString,
|
||||
object : InvokeInterface {
|
||||
override fun onResult(result: String?) {
|
||||
callback(result)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private external fun quickSetup(
|
||||
initParamsString: String,
|
||||
setupParamsString: String,
|
||||
cb: InvokeInterface,
|
||||
)
|
||||
|
||||
external fun stopTun()
|
||||
|
||||
external fun getTraffic(onlyStatisticsProxy: Boolean): String
|
||||
@@ -111,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,3 +1,6 @@
|
||||
org.gradle.jvmargs=-Xmx4G
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
# This builtInKotlin flag was added automatically by Flutter migrator
|
||||
android.builtInKotlin=false
|
||||
# This newDsl flag was added automatically by Flutter migrator
|
||||
android.newDsl=false
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
[versions]
|
||||
#agp = "8.10.1"
|
||||
firebaseBom = "34.2.0"
|
||||
firebaseBom = "34.15.0"
|
||||
minSdk = "23"
|
||||
targetSdk = "36"
|
||||
compileSdk = "36"
|
||||
ndkVersion = "28.0.13004108"
|
||||
ndkVersion = "28.2.13676358"
|
||||
coreKtx = "1.17.0"
|
||||
annotationJvm = "1.9.1"
|
||||
coreSplashscreen = "1.0.1"
|
||||
gson = "2.13.1"
|
||||
kotlin = "2.2.10"
|
||||
smaliDexlib2 = "3.0.9"
|
||||
firebaseCrashlyticsKtx = "20.0.1"
|
||||
firebaseCommonKtx = "22.0.0"
|
||||
|
||||
[libraries]
|
||||
build-kotlin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
|
||||
androidx-core = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
|
||||
annotation-jvm = { module = "androidx.annotation:annotation-jvm", version.ref = "annotationJvm" }
|
||||
core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" }
|
||||
@@ -24,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
-1
@@ -2,5 +2,5 @@ distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-all.zip
|
||||
|
||||
|
||||
@@ -2,35 +2,21 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("kotlin-parcelize")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.follow.clash.service"
|
||||
compileSdk = 36
|
||||
compileSdk = libs.versions.compileSdk.get().toInt()
|
||||
|
||||
defaultConfig {
|
||||
minSdk = libs.versions.minSdk.get().toInt()
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
aidl = true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
@@ -39,10 +25,9 @@ kotlin {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core"))
|
||||
implementation(project(":common"))
|
||||
implementation(libs.gson)
|
||||
implementation(libs.androidx.core)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,18 +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.models.VpnOptions;
|
||||
import com.follow.clash.service.models.NotificationParams;
|
||||
|
||||
interface IRemoteInterface {
|
||||
void invokeAction(in String data, in ICallbackInterface callback);
|
||||
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,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;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user