Compare commits
1 Commits
main
...
v0.8.95-pre.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 22afef0a3c |
+48
-7
@@ -21,7 +21,7 @@ Desktop core mode:
|
||||
Key Go core files:
|
||||
|
||||
- `core/hub.go`: handler functions.
|
||||
- `core/action.go`: dispatch.
|
||||
- `core/method.go`: MethodChannel-style method-call dispatch and response envelopes.
|
||||
- `core/lib.go`: CGO exports.
|
||||
- `core/server.go`: socket server.
|
||||
|
||||
@@ -71,6 +71,10 @@ Each manager in `lib/manager/` handles a specific platform concern. Desktop-only
|
||||
|
||||
`lib/core/controller.dart` (`CoreController`) is a singleton facade over `CoreHandlerInterface`. Public methods delegate to the platform-specific interface, either Android FFI or desktop socket. It has an `@visibleForTesting` constructor and `resetInstance()` for test injection.
|
||||
|
||||
The shared core protocol uses `CoreMethodCall(method, arguments)` in both directions and `CoreMethodResponse(result, error)` for replies. Desktop calls include an `id` for concurrent request correlation; Android carries the same JSON envelope through its service `MethodChannel` and JNI bridge.
|
||||
The envelope is the only JSON serialization layer: keep `arguments`, `result`, and event data as structured JSON values instead
|
||||
of embedding pre-encoded JSON strings. Plain domain strings, such as country codes or provider file contents, remain strings.
|
||||
|
||||
Business logic lives in Riverpod notifier classes in `lib/providers/action.dart`:
|
||||
|
||||
- `CommonAction`: update check and common UI operations.
|
||||
@@ -121,25 +125,62 @@ Platform build hooks inside `flutter build` trigger `build_tool` automatically:
|
||||
|
||||
- macOS: podspec script phase, `build_pod.sh`, `build_tool macos`.
|
||||
- Linux: CMake include, `buildkit/cmake/buildkit.cmake`, `build_tool linux`.
|
||||
- Windows: CMake include, `buildkit/cmake/buildkit.cmake`, `build_tool windows`. Debug passes `--dev` via `CMAKE_BUILD_TYPE`.
|
||||
- Windows: CMake include, `buildkit/cmake/buildkit.cmake`, `build_tool windows`. CMake forwards the active configuration through `BUILDKIT_CONFIGURATION`.
|
||||
- Android: Gradle include, `buildkit/gradle/plugin.gradle`, `build_tool android`.
|
||||
|
||||
### 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. On Windows it runs the same build tool before Flutter compilation
|
||||
because the core SHA must be available for `--dart-define`.
|
||||
|
||||
Platform outputs remain explicit:
|
||||
|
||||
- Android builds the Go core as `c-shared`, then copies `libclash.so` and generated headers into the `:core` Android module.
|
||||
- macOS and Linux build a standalone `FlClashCore` process used by the desktop socket integration.
|
||||
- Windows builds `FlClashCore.exe` plus the Rust `FlClashHelperService.exe` privileged helper.
|
||||
|
||||
The hooks follow rust_api/Cargokit's phony-output scheduling pattern, but setup uses its own cache because it builds both a
|
||||
Go core and, on Windows, a separate Rust helper. Per-target records live under `.dart_tool/setup_build_cache/v1/`:
|
||||
|
||||
- Go fingerprints cover the target-specific `go list -deps` inputs inside `core/` and `Clash.Meta`, module files, effective
|
||||
build configuration, build-tool sources, target flags, Go environment/toolchain, and Android NDK compiler details.
|
||||
- Windows helper fingerprints cover its Rust sources and manifests, Cargo/Rust toolchains and flags, build mode, and the
|
||||
release core SHA token.
|
||||
- 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 auth:
|
||||
|
||||
- Release: Core SHA256 is embedded in both the Flutter app and the Rust helper. The app pings the helper and verifies the token matches.
|
||||
- Debug: The Rust helper skips token verification when built in debug mode, so `flutter run` works without the SHA256 flow.
|
||||
|
||||
`plugins/setup/` is an FFI plugin that exists only as a build harness. It carries no Dart API, only platform build hooks that trigger Go compilation. Windows builds also compile a Rust helper in `services/helper/` through `RustBuilder`.
|
||||
|
||||
Build configuration defaults live in `build_tool/lib/src/options.dart` and can be overridden via `build_config.yaml`.
|
||||
Build configuration defaults live in `build_tool/lib/src/options.dart` and can be overridden via a root `build_config.yaml`.
|
||||
|
||||
Architecture detection is automatic. The `--description` flag passed to `flutter_distributor` adds arch suffixes to artifact names, such as `FlClash-0.8.93-macos-arm64.dmg`.
|
||||
|
||||
## Local Plugins
|
||||
|
||||
- `setup`: build harness FFI plugin.
|
||||
- `setup`: build-time harness for Go core artifacts and the Windows Rust helper; no runtime Dart API.
|
||||
- `proxy`: system proxy configuration.
|
||||
- `rust_api`: Flutter Rust Bridge FFI plugin.
|
||||
- `rust_api`: runtime Flutter Rust Bridge FFI plugin built through Cargokit.
|
||||
- `tray_manager`: system tray fork/customization.
|
||||
- `wifi_ssid`: Wi-Fi SSID detection.
|
||||
- `window_ext`: window extensions.
|
||||
|
||||
@@ -33,6 +33,9 @@ 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
|
||||
|
||||
@@ -23,6 +23,10 @@ Generated directories are excluded from analysis:
|
||||
|
||||
## Testing Rules
|
||||
|
||||
The `core/` directory is excluded from automated test coverage. Do not add `*_test.go` files, coverage instrumentation,
|
||||
or coverage collection for code under `core/`. Verify core protocol behavior through shared Dart contract tests under
|
||||
`test/core/` and native platform build checks instead.
|
||||
|
||||
Use `CoreController.test(mock)` to inject a mocked `CoreHandlerInterface`. Call `CoreController.resetInstance()` in `tearDown` to clean up the singleton between tests.
|
||||
|
||||
Register fallback values for freezed params used with `any()` matchers.
|
||||
|
||||
@@ -4,6 +4,7 @@ PLATFORM ?= macos
|
||||
BUILDKIT := plugins/setup/buildkit/run_build_tool.sh
|
||||
ARCH_ARG := $(if $(ARCH),--arch $(ARCH),)
|
||||
TARGET_PLATFORM_ARG := $(if $(TARGET_PLATFORM),--target-platform $(TARGET_PLATFORM),)
|
||||
FORCE_ARG := $(if $(filter 1 true yes,$(FORCE)),--force,)
|
||||
|
||||
.PHONY: help submodules core core-macos core-linux core-windows core-android
|
||||
|
||||
@@ -13,12 +14,13 @@ help:
|
||||
@echo 'make core-macos ARCH=arm64'
|
||||
@echo 'make core-android ARCH=arm64'
|
||||
@echo 'make core-android TARGET_PLATFORM=android-arm64'
|
||||
@echo 'make core-macos FORCE=1 # bypass setup build cache'
|
||||
|
||||
submodules:
|
||||
git submodule update --init --recursive
|
||||
|
||||
core:
|
||||
bash $(BUILDKIT) $(PLATFORM) $(ARCH_ARG) $(TARGET_PLATFORM_ARG)
|
||||
bash $(BUILDKIT) $(PLATFORM) $(ARCH_ARG) $(TARGET_PLATFORM_ARG) $(FORCE_ARG)
|
||||
|
||||
core-macos:
|
||||
$(MAKE) core PLATFORM=macos
|
||||
|
||||
@@ -15,21 +15,20 @@ val localProperties = Properties().apply {
|
||||
}
|
||||
}
|
||||
|
||||
val mStoreFile: File = file("keystore.jks")
|
||||
val mStorePassword: String? = localProperties.getProperty("storePassword")
|
||||
val mKeyAlias: String? = localProperties.getProperty("keyAlias")
|
||||
val mKeyPassword: String? = localProperties.getProperty("keyPassword")
|
||||
val isRelease =
|
||||
mStoreFile.exists() && mStorePassword != null && mKeyAlias != null && mKeyPassword != null
|
||||
|
||||
val releaseStoreFile = file("keystore.jks")
|
||||
val releaseStorePassword = localProperties.getProperty("storePassword")
|
||||
val releaseKeyAlias = localProperties.getProperty("keyAlias")
|
||||
val releaseKeyPassword = localProperties.getProperty("keyPassword")
|
||||
val hasReleaseSigning = releaseStoreFile.exists() &&
|
||||
releaseStorePassword != null &&
|
||||
releaseKeyAlias != null &&
|
||||
releaseKeyPassword != null
|
||||
|
||||
android {
|
||||
namespace = "com.follow.clash"
|
||||
compileSdk = libs.versions.compileSdk.get().toInt()
|
||||
ndkVersion = libs.versions.ndkVersion.get()
|
||||
|
||||
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
@@ -44,12 +43,12 @@ android {
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
if (isRelease) {
|
||||
if (hasReleaseSigning) {
|
||||
create("release") {
|
||||
storeFile = mStoreFile
|
||||
storePassword = mStorePassword
|
||||
keyAlias = mKeyAlias
|
||||
keyPassword = mKeyPassword
|
||||
storeFile = releaseStoreFile
|
||||
storePassword = releaseStorePassword
|
||||
keyAlias = releaseKeyAlias
|
||||
keyPassword = releaseKeyPassword
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,7 +68,7 @@ android {
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
if (isRelease) {
|
||||
if (hasReleaseSigning) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
} else {
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
@@ -77,7 +76,8 @@ android {
|
||||
}
|
||||
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -93,10 +93,10 @@ flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
|
||||
dependencies {
|
||||
implementation(project(":service"))
|
||||
implementation(project(":common"))
|
||||
implementation(project(":core"))
|
||||
implementation(libs.core.splashscreen)
|
||||
implementation(libs.gson)
|
||||
implementation(libs.smali.dexlib2) {
|
||||
|
||||
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,10 +11,12 @@
|
||||
<uses-feature
|
||||
android:name="android.software.leanback"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.wifi"
|
||||
android:required="false" />
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
||||
@@ -23,15 +25,14 @@
|
||||
tools:ignore="QueryAllPackagesPermission" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.POST_PROMOTED_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
|
||||
<application
|
||||
android:name=".Application"
|
||||
android:name=".FlClashApplication"
|
||||
android:banner="@mipmap/ic_banner"
|
||||
android:hardwareAccelerated="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="FlClash">
|
||||
android:label="@string/app_name">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
@@ -68,7 +69,7 @@
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".TempActivity"
|
||||
android:name=".QuickActionActivity"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="true"
|
||||
android:theme="@style/TransparentTheme">
|
||||
@@ -90,7 +91,7 @@
|
||||
android:name=".TileService"
|
||||
android:exported="true"
|
||||
android:icon="@drawable/ic"
|
||||
android:label="FlClash"
|
||||
android:label="@string/app_name"
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||
@@ -101,7 +102,7 @@
|
||||
</service>
|
||||
|
||||
<receiver
|
||||
android:name=".BroadcastReceiver"
|
||||
android:name=".ServiceBroadcastReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="true"
|
||||
android:permission="${applicationId}.permission.RECEIVE_BROADCASTS">
|
||||
@@ -115,4 +116,4 @@
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
</application>
|
||||
</manifest>
|
||||
</manifest>
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
package com.follow.clash
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.follow.clash.common.BroadcastAction
|
||||
import com.follow.clash.common.GlobalState
|
||||
import com.follow.clash.common.action
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class BroadcastReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
when (intent?.action) {
|
||||
BroadcastAction.SERVICE_CREATED.action -> {
|
||||
GlobalState.log("Receiver service created")
|
||||
GlobalState.launch {
|
||||
State.handleStartServiceAction()
|
||||
}
|
||||
}
|
||||
|
||||
BroadcastAction.SERVICE_DESTROYED.action -> {
|
||||
GlobalState.log("Receiver service destroyed")
|
||||
GlobalState.launch {
|
||||
State.handleStopServiceAction()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,37 +17,32 @@ import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
private const val ICON_TTL_DAYS = 1L
|
||||
|
||||
val Application.sharedState: SharedState
|
||||
get() {
|
||||
try {
|
||||
val sp = getSharedPreferences("FlutterSharedPreferences", MODE_PRIVATE)
|
||||
val res = sp.getString("flutter.sharedState", "")
|
||||
return Gson().fromJson(res, SharedState::class.java)
|
||||
} catch (_: Exception) {
|
||||
return SharedState()
|
||||
}
|
||||
get() = try {
|
||||
val preferences = getSharedPreferences("FlutterSharedPreferences", MODE_PRIVATE)
|
||||
val json = preferences.getString("flutter.sharedState", null)
|
||||
Gson().fromJson(json, SharedState::class.java) ?: SharedState()
|
||||
} catch (_: Exception) {
|
||||
SharedState()
|
||||
}
|
||||
|
||||
|
||||
private var lastToast: Toast? = null
|
||||
|
||||
fun Application.showToast(text: String?) {
|
||||
if (text.isNullOrEmpty()) return
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
lastToast?.cancel()
|
||||
lastToast = Toast.makeText(this, text, Toast.LENGTH_LONG).apply {
|
||||
show()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
suspend fun PackageManager.getPackageIconPath(packageName: String): String =
|
||||
@@ -55,8 +50,7 @@ suspend fun PackageManager.getPackageIconPath(packageName: String): String =
|
||||
val cacheDir = GlobalState.application.cacheDir
|
||||
val iconDir = File(cacheDir, "icons").apply { mkdirs() }
|
||||
return@withContext try {
|
||||
val pkgInfo = getPackageInfo(packageName, 0)
|
||||
val lastUpdateTime = pkgInfo.lastUpdateTime
|
||||
val lastUpdateTime = getPackageInfo(packageName, 0).lastUpdateTime
|
||||
val iconFile = File(iconDir, "${packageName}_${lastUpdateTime}.webp")
|
||||
if (iconFile.exists() && !isExpired(iconFile)) {
|
||||
return@withContext iconFile.absolutePath
|
||||
@@ -80,14 +74,11 @@ private suspend fun saveDrawableToFile(drawable: Drawable, file: File) {
|
||||
drawable.toBitmap(width = 128, height = 128)
|
||||
}
|
||||
try {
|
||||
val format = when {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> {
|
||||
Bitmap.CompressFormat.WEBP_LOSSY
|
||||
}
|
||||
|
||||
else -> {
|
||||
Bitmap.CompressFormat.WEBP
|
||||
}
|
||||
@Suppress("DEPRECATION")
|
||||
val format = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
Bitmap.CompressFormat.WEBP_LOSSY
|
||||
} else {
|
||||
Bitmap.CompressFormat.WEBP
|
||||
}
|
||||
FileOutputStream(file).use { fos ->
|
||||
bitmap.compress(format, 90, fos)
|
||||
@@ -103,48 +94,12 @@ private fun isExpired(file: File): Boolean {
|
||||
return age > TimeUnit.DAYS.toMillis(ICON_TTL_DAYS)
|
||||
}
|
||||
|
||||
suspend fun <T> MethodChannel.awaitResult(
|
||||
method: String, arguments: Any? = null
|
||||
): T? = withContext(Dispatchers.Main) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
invokeMethod(method, arguments, object : MethodChannel.Result {
|
||||
override fun success(result: Any?) {
|
||||
@Suppress("UNCHECKED_CAST") continuation.resume(result as T?)
|
||||
}
|
||||
|
||||
override fun error(code: String, message: String?, details: Any?) {
|
||||
continuation.resume(null)
|
||||
}
|
||||
|
||||
override fun notImplemented() {
|
||||
continuation.resume(null)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <reified T : FlutterPlugin> FlutterEngine.plugin(): T? {
|
||||
return plugins.get(T::class.java) as T?
|
||||
}
|
||||
|
||||
fun <T> MethodChannel.invokeMethodOnMainThread(
|
||||
method: String, arguments: Any? = null, callback: ((Result<T>) -> Unit)? = null
|
||||
) {
|
||||
fun MethodChannel.invokeMethodOnMainThread(method: String, arguments: Any? = null) {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
invokeMethod(method, arguments, object : MethodChannel.Result {
|
||||
override fun success(result: Any?) {
|
||||
@Suppress("UNCHECKED_CAST") callback?.invoke(Result.success(result as T))
|
||||
}
|
||||
|
||||
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
|
||||
val exception = Exception("MethodChannel error: $errorCode - $errorMessage")
|
||||
callback?.invoke(Result.failure(exception))
|
||||
}
|
||||
|
||||
override fun notImplemented() {
|
||||
val exception = NotImplementedError("Method not implemented: $method")
|
||||
callback?.invoke(Result.failure(exception))
|
||||
}
|
||||
})
|
||||
invokeMethod(method, arguments)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -4,8 +4,7 @@ import android.app.Application
|
||||
import android.content.Context
|
||||
import com.follow.clash.common.GlobalState
|
||||
|
||||
class Application : Application() {
|
||||
|
||||
class FlClashApplication : Application() {
|
||||
override fun attachBaseContext(base: Context?) {
|
||||
super.attachBaseContext(base)
|
||||
GlobalState.init(this)
|
||||
@@ -1,37 +1,22 @@
|
||||
package com.follow.clash
|
||||
|
||||
import android.os.Bundle
|
||||
import com.follow.clash.common.GlobalState
|
||||
import com.follow.clash.plugins.AppPlugin
|
||||
import com.follow.clash.plugins.ServicePlugin
|
||||
import com.follow.clash.plugins.TilePlugin
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MainActivity : FlutterActivity(),
|
||||
CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
}
|
||||
|
||||
class MainActivity : FlutterActivity() {
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
flutterEngine.plugins.add(AppPlugin())
|
||||
flutterEngine.plugins.add(ServicePlugin())
|
||||
flutterEngine.plugins.add(TilePlugin())
|
||||
State.flutterEngine = flutterEngine
|
||||
ServiceState.attachFlutterEngine(flutterEngine)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
GlobalState.launch {
|
||||
Service.setEventListener(null)
|
||||
}
|
||||
State.flutterEngine = null
|
||||
flutterEngine?.let(ServiceState::detachFlutterEngine)
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.follow.clash
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Bundle
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import com.follow.clash.common.GlobalState
|
||||
import com.follow.clash.common.QuickAction
|
||||
import com.follow.clash.common.action
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class QuickActionActivity : Activity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
when (intent.action) {
|
||||
QuickAction.START.action -> GlobalState.launch { ServiceState.handleStartAction() }
|
||||
QuickAction.STOP.action -> GlobalState.launch { ServiceState.handleStopAction() }
|
||||
QuickAction.TOGGLE.action -> {
|
||||
ShortcutManagerCompat.reportShortcutUsed(this, SHORTCUT_ID)
|
||||
GlobalState.launch { ServiceState.handleToggleAction() }
|
||||
}
|
||||
}
|
||||
finish()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val SHORTCUT_ID = "toggle"
|
||||
}
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
package com.follow.clash
|
||||
|
||||
import com.follow.clash.common.GlobalState
|
||||
import com.follow.clash.common.ServiceDelegate
|
||||
import com.follow.clash.common.formatString
|
||||
import com.follow.clash.common.intent
|
||||
import com.follow.clash.service.IAckInterface
|
||||
import com.follow.clash.service.ICallbackInterface
|
||||
import com.follow.clash.service.IEventInterface
|
||||
import com.follow.clash.service.IRemoteInterface
|
||||
import com.follow.clash.service.IResultInterface
|
||||
import com.follow.clash.service.IVoidInterface
|
||||
import com.follow.clash.service.RemoteService
|
||||
import com.follow.clash.service.models.NotificationParams
|
||||
import com.follow.clash.service.models.VpnOptions
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
object Service {
|
||||
private val delegate by lazy {
|
||||
ServiceDelegate<IRemoteInterface>(
|
||||
RemoteService::class.intent, ::handleServiceDisconnected
|
||||
) {
|
||||
IRemoteInterface.Stub.asInterface(it)
|
||||
}
|
||||
}
|
||||
|
||||
var onServiceDisconnected: ((String) -> Unit)? = null
|
||||
|
||||
private fun handleServiceDisconnected(message: String) {
|
||||
onServiceDisconnected?.let {
|
||||
it(message)
|
||||
}
|
||||
}
|
||||
|
||||
fun bind() {
|
||||
delegate.bind()
|
||||
}
|
||||
|
||||
fun unbind() {
|
||||
delegate.unbind()
|
||||
}
|
||||
|
||||
suspend fun invokeAction(data: String, cb: ((result: String) -> Unit)?): Result<Unit> {
|
||||
val res = mutableListOf<ByteArray>()
|
||||
return delegate.useService {
|
||||
it.invokeAction(
|
||||
data, object : ICallbackInterface.Stub() {
|
||||
override fun onResult(
|
||||
result: ByteArray?, isSuccess: Boolean, ack: IAckInterface?
|
||||
) {
|
||||
res.add(result ?: byteArrayOf())
|
||||
ack?.onAck()
|
||||
if (isSuccess) {
|
||||
cb?.let { cb ->
|
||||
cb(res.formatString())
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun quickSetup(
|
||||
initParamsString: String,
|
||||
setupParamsString: String,
|
||||
onStarted: (() -> Unit)?,
|
||||
onResult: ((result: String) -> Unit)?,
|
||||
): Result<Unit> {
|
||||
val res = mutableListOf<ByteArray>()
|
||||
return delegate.useService {
|
||||
it.quickSetup(
|
||||
initParamsString,
|
||||
setupParamsString,
|
||||
object : ICallbackInterface.Stub() {
|
||||
override fun onResult(
|
||||
result: ByteArray?, isSuccess: Boolean, ack: IAckInterface?
|
||||
) {
|
||||
res.add(result ?: byteArrayOf())
|
||||
ack?.onAck()
|
||||
if (isSuccess) {
|
||||
onResult?.let { cb ->
|
||||
cb(res.formatString())
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
object : IVoidInterface.Stub() {
|
||||
override fun invoke() {
|
||||
onStarted?.let { onStarted ->
|
||||
onStarted()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setEventListener(
|
||||
cb: ((result: String?) -> Unit)?
|
||||
): Result<Unit> {
|
||||
val results = HashMap<String, MutableList<ByteArray>>()
|
||||
return delegate.useService {
|
||||
it.setEventListener(
|
||||
when (cb != null) {
|
||||
true -> object : IEventInterface.Stub() {
|
||||
override fun onEvent(
|
||||
id: String, data: ByteArray?, isSuccess: Boolean, ack: IAckInterface?
|
||||
) {
|
||||
if (results[id] == null) {
|
||||
results[id] = mutableListOf()
|
||||
}
|
||||
results[id]?.add(data ?: byteArrayOf())
|
||||
ack?.onAck()
|
||||
if (isSuccess) {
|
||||
cb(results[id]?.formatString())
|
||||
results.remove(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false -> null
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateNotificationParams(
|
||||
params: NotificationParams
|
||||
): Result<Unit> {
|
||||
return delegate.useService {
|
||||
it.updateNotificationParams(params)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setCrashlytics(
|
||||
enable: Boolean
|
||||
): Result<Unit> {
|
||||
return delegate.useService {
|
||||
it.setCrashlytics(enable)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun awaitIResultInterface(
|
||||
block: (IResultInterface) -> Unit
|
||||
): Long = suspendCancellableCoroutine { continuation ->
|
||||
val callback = object : IResultInterface.Stub() {
|
||||
override fun onResult(time: Long) {
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(time)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
block(callback)
|
||||
} catch (e: Exception) {
|
||||
GlobalState.log("awaitIResultInterface $e")
|
||||
if (continuation.isActive) {
|
||||
continuation.resumeWithException(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
suspend fun startService(options: VpnOptions, runTime: Long): Long {
|
||||
return delegate.useService {
|
||||
awaitIResultInterface { callback ->
|
||||
it.startService(options, runTime, callback)
|
||||
}
|
||||
}.getOrNull() ?: 0L
|
||||
}
|
||||
|
||||
suspend fun stopService(): Long {
|
||||
return delegate.useService {
|
||||
awaitIResultInterface { callback ->
|
||||
it.stopService(callback)
|
||||
}
|
||||
}.getOrNull() ?: 0L
|
||||
}
|
||||
|
||||
suspend fun getRunTime(): Long {
|
||||
return delegate.useService {
|
||||
it.runTime
|
||||
}.getOrNull() ?: 0L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
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 ServiceBroadcastReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
val action = intent?.action ?: return
|
||||
val pendingResult = goAsync()
|
||||
GlobalState.launch {
|
||||
try {
|
||||
handleAction(action)
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleAction(action: String) {
|
||||
when (action) {
|
||||
BroadcastAction.SERVICE_CREATED.action -> {
|
||||
GlobalState.log("Background service created")
|
||||
ServiceState.handleStartAction()
|
||||
}
|
||||
|
||||
BroadcastAction.SERVICE_DESTROYED.action -> {
|
||||
GlobalState.log("Background service destroyed")
|
||||
ServiceState.handleStopAction()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
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.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
object ServiceController {
|
||||
private val lock = Mutex()
|
||||
private var binding: ManagedServiceBinding? = null
|
||||
@Volatile
|
||||
private var runTimeMillis = 0L
|
||||
@Volatile
|
||||
private var serviceDisconnectedListener: ((String) -> Unit)? = null
|
||||
|
||||
fun setServiceDisconnectedListener(listener: ((String) -> Unit)?) {
|
||||
serviceDisconnectedListener = listener
|
||||
}
|
||||
|
||||
suspend fun unbind() = lock.withLock {
|
||||
clearBinding()
|
||||
}
|
||||
|
||||
private fun clearBinding() {
|
||||
binding?.unbind()
|
||||
binding = null
|
||||
}
|
||||
|
||||
fun invokeMethod(data: String, callback: ((String) -> Unit)?): Result<Unit> = runCatching {
|
||||
Core.invokeMethod(data) { result ->
|
||||
callback?.invoke(result.orEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun quickSetup(
|
||||
initParams: String,
|
||||
setupParams: String,
|
||||
): Result<String> = runCatching {
|
||||
suspendCoroutine { continuation ->
|
||||
Core.quickSetup(initParams, setupParams) { result ->
|
||||
continuation.resume(result.orEmpty())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setEventListener(callback: ((String?) -> Unit)?): Result<Unit> = runCatching {
|
||||
Core.updateEventListener(callback)
|
||||
}
|
||||
|
||||
suspend fun start(options: VpnOptions, previousRunTimeMillis: Long): Long = lock.withLock {
|
||||
ServiceConfig.updateVpnOptions(options)
|
||||
val nextIntent = if (options.enable) {
|
||||
VpnService::class.intent
|
||||
} else {
|
||||
ProxyService::class.intent
|
||||
}
|
||||
|
||||
if (binding?.component != nextIntent.component) {
|
||||
clearBinding()
|
||||
lateinit var nextBinding: ManagedServiceBinding
|
||||
nextBinding = ManagedServiceBinding(nextIntent) { message ->
|
||||
handleServiceDisconnected(nextBinding, message)
|
||||
}
|
||||
binding = nextBinding
|
||||
nextBinding.bind().onFailure { error ->
|
||||
GlobalState.log("Unable to bind background service: $error")
|
||||
clearBinding()
|
||||
return@withLock 0L
|
||||
}
|
||||
}
|
||||
|
||||
val currentBinding = binding ?: return@withLock 0L
|
||||
val result = currentBinding.useService { service -> service.start() }
|
||||
if (result.isFailure) {
|
||||
GlobalState.log("Unable to start background service: ${result.exceptionOrNull()}")
|
||||
clearBinding()
|
||||
return@withLock 0L
|
||||
}
|
||||
|
||||
runTimeMillis = previousRunTimeMillis.takeIf { it != 0L }
|
||||
?: System.currentTimeMillis()
|
||||
runTimeMillis
|
||||
}
|
||||
|
||||
suspend fun stop(): Long = lock.withLock {
|
||||
binding?.useService { service -> service.stop() }
|
||||
?.onFailure { error ->
|
||||
GlobalState.log("Unable to stop background service: $error")
|
||||
}
|
||||
clearBinding()
|
||||
runTimeMillis = 0L
|
||||
runTimeMillis
|
||||
}
|
||||
|
||||
fun getRunTimeMillis(): Long = runTimeMillis
|
||||
|
||||
private fun handleServiceDisconnected(
|
||||
disconnectedBinding: ManagedServiceBinding,
|
||||
message: String,
|
||||
) {
|
||||
GlobalState.launch {
|
||||
val shouldNotify = lock.withLock {
|
||||
if (binding !== disconnectedBinding) {
|
||||
return@withLock false
|
||||
}
|
||||
GlobalState.log("Background service disconnected: $message")
|
||||
clearBinding()
|
||||
runTimeMillis = 0L
|
||||
true
|
||||
}
|
||||
if (shouldNotify) {
|
||||
serviceDisconnectedListener?.invoke(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class ManagedServiceBinding(
|
||||
private val intent: Intent,
|
||||
private val onDisconnected: (String) -> Unit,
|
||||
) : ServiceConnection {
|
||||
val component: ComponentName?
|
||||
get() = intent.component
|
||||
|
||||
private val serviceState = MutableStateFlow<Result<ManagedService>?>(null)
|
||||
|
||||
@Volatile
|
||||
private var isBound = false
|
||||
|
||||
suspend fun bind(): Result<Unit> = runCatching {
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
serviceState.value = null
|
||||
isBound = GlobalState.application.bindService(
|
||||
intent,
|
||||
this@ManagedServiceBinding,
|
||||
Context.BIND_AUTO_CREATE,
|
||||
)
|
||||
check(isBound) { "bindService() failed" }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun <R> useService(
|
||||
timeoutMillis: Long = 5_000,
|
||||
block: suspend (ManagedService) -> R,
|
||||
): Result<R> = runCatching {
|
||||
withTimeout(timeoutMillis) {
|
||||
val service = serviceState.filterNotNull().first().getOrThrow()
|
||||
withContext(Dispatchers.Default) {
|
||||
block(service)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun unbind() {
|
||||
serviceState.value = null
|
||||
if (!isBound) return
|
||||
isBound = false
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
runCatching {
|
||||
GlobalState.application.unbindService(this)
|
||||
}.onFailure { error ->
|
||||
GlobalState.log("Unable to unbind background service: $error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
|
||||
runCatching {
|
||||
when (binder) {
|
||||
is VpnService.LocalBinder -> binder.service
|
||||
is ProxyService.LocalBinder -> binder.service
|
||||
null -> error("Binder is empty")
|
||||
else -> error("Unsupported service binder: ${binder.javaClass.name}")
|
||||
}
|
||||
}.onSuccess { service ->
|
||||
serviceState.value = Result.success(service)
|
||||
}.onFailure { error ->
|
||||
disconnect(error.message.orEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
disconnect("Service disconnected")
|
||||
}
|
||||
|
||||
override fun onBindingDied(name: ComponentName?) {
|
||||
disconnect("Service binding died")
|
||||
}
|
||||
|
||||
override fun onNullBinding(name: ComponentName?) {
|
||||
disconnect("Service returned an empty binder")
|
||||
}
|
||||
|
||||
private fun disconnect(message: String) {
|
||||
serviceState.value = Result.failure(IllegalStateException(message))
|
||||
onDisconnected(message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
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 kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
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 lock = Mutex()
|
||||
private val mutableRunState = MutableStateFlow(RunState.STOPPED)
|
||||
@Volatile
|
||||
private var sharedState = SharedState()
|
||||
@Volatile
|
||||
private var flutterEngine: FlutterEngine? = null
|
||||
|
||||
val runState = mutableRunState.asStateFlow()
|
||||
|
||||
var runTimeMillis = 0L
|
||||
private set
|
||||
|
||||
private val appPlugin: AppPlugin?
|
||||
get() = flutterEngine?.plugin<AppPlugin>()
|
||||
|
||||
private val tilePlugin: TilePlugin?
|
||||
get() = flutterEngine?.plugin<TilePlugin>()
|
||||
|
||||
fun attachFlutterEngine(engine: FlutterEngine) {
|
||||
flutterEngine = engine
|
||||
}
|
||||
|
||||
fun detachFlutterEngine(engine: FlutterEngine) {
|
||||
if (flutterEngine === engine) {
|
||||
flutterEngine = null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun handleToggleAction() {
|
||||
when (runState.value) {
|
||||
RunState.STARTED -> handleStopAction()
|
||||
RunState.STOPPED -> handleStartAction()
|
||||
RunState.STARTING, RunState.STOPPING -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun refresh() = lock.withLock {
|
||||
if (runState.value == RunState.STARTING || runState.value == RunState.STOPPING) {
|
||||
return@withLock
|
||||
}
|
||||
runTimeMillis = ServiceController.getRunTimeMillis()
|
||||
mutableRunState.value = if (runTimeMillis == 0L) RunState.STOPPED else RunState.STARTED
|
||||
}
|
||||
|
||||
suspend fun handleStartAction() {
|
||||
val shouldStartInBackground = lock.withLock {
|
||||
if (runState.value != RunState.STOPPED) {
|
||||
return@withLock false
|
||||
}
|
||||
tilePlugin?.handleStart()
|
||||
flutterEngine == null
|
||||
}
|
||||
if (shouldStartInBackground) {
|
||||
loadPreferencesAndStart()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun handleStopAction() {
|
||||
val shouldStopInBackground = lock.withLock {
|
||||
if (runState.value != RunState.STARTED) {
|
||||
return@withLock false
|
||||
}
|
||||
tilePlugin?.handleStop()
|
||||
flutterEngine == null
|
||||
}
|
||||
if (shouldStopInBackground) {
|
||||
GlobalState.application.showToast(sharedState.stopTip)
|
||||
launchStop()
|
||||
}
|
||||
}
|
||||
|
||||
fun requestStart() {
|
||||
val plugin = appPlugin
|
||||
if (plugin == null) {
|
||||
launchStart()
|
||||
return
|
||||
}
|
||||
plugin.requestNotificationPermission(::launchStart)
|
||||
}
|
||||
|
||||
fun requestStop() {
|
||||
launchStop()
|
||||
}
|
||||
|
||||
fun syncSharedState(state: SharedState) {
|
||||
sharedState = state
|
||||
applySharedState()
|
||||
}
|
||||
|
||||
fun handleServiceDisconnected() {
|
||||
GlobalState.launch {
|
||||
lock.withLock {
|
||||
runTimeMillis = 0L
|
||||
mutableRunState.value = RunState.STOPPED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadPreferencesAndStart() {
|
||||
sharedState = GlobalState.application.sharedState
|
||||
if (sharedState.setupParams == null || sharedState.vpnOptions == null) {
|
||||
GlobalState.application.showToast(MISSING_CONFIG_MESSAGE)
|
||||
return
|
||||
}
|
||||
if (setupCore()) {
|
||||
startInBackground()
|
||||
}
|
||||
}
|
||||
|
||||
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 fun launchStart() {
|
||||
GlobalState.launch {
|
||||
val options = beginStart() ?: return@launch
|
||||
|
||||
val plugin = appPlugin
|
||||
if (plugin != null) {
|
||||
plugin.prepareVpn(options.enable) { granted ->
|
||||
if (granted) {
|
||||
completeStart(options)
|
||||
} else {
|
||||
cancelStart()
|
||||
}
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
if (options.enable && VpnService.prepare(GlobalState.application) != null) {
|
||||
cancelStartNow()
|
||||
return@launch
|
||||
}
|
||||
completeStartNow(options)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun startInBackground() {
|
||||
val options = beginStart() ?: return
|
||||
if (options.enable && VpnService.prepare(GlobalState.application) != null) {
|
||||
cancelStartNow()
|
||||
GlobalState.application.showToast(VPN_PERMISSION_MESSAGE)
|
||||
return
|
||||
}
|
||||
if (!completeStartNow(options)) {
|
||||
GlobalState.application.showToast(START_FAILED_MESSAGE)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun beginStart(): VpnOptions? = lock.withLock {
|
||||
if (runState.value != RunState.STOPPED) {
|
||||
return@withLock null
|
||||
}
|
||||
val value = sharedState.vpnOptions ?: return@withLock null
|
||||
mutableRunState.value = RunState.STARTING
|
||||
value
|
||||
}
|
||||
|
||||
private fun completeStart(options: VpnOptions) {
|
||||
GlobalState.launch {
|
||||
completeStartNow(options)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun completeStartNow(options: VpnOptions): Boolean = lock.withLock {
|
||||
if (runState.value != RunState.STARTING) {
|
||||
return@withLock false
|
||||
}
|
||||
runTimeMillis = ServiceController.start(options, runTimeMillis)
|
||||
mutableRunState.value =
|
||||
if (runTimeMillis == 0L) RunState.STOPPED else RunState.STARTED
|
||||
runTimeMillis != 0L
|
||||
}
|
||||
|
||||
private fun cancelStart() {
|
||||
GlobalState.launch {
|
||||
cancelStartNow()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun cancelStartNow() = lock.withLock {
|
||||
if (runState.value == RunState.STARTING) {
|
||||
mutableRunState.value = RunState.STOPPED
|
||||
}
|
||||
}
|
||||
|
||||
private fun launchStop() {
|
||||
GlobalState.launch {
|
||||
lock.withLock {
|
||||
if (runState.value != RunState.STARTED) {
|
||||
return@withLock
|
||||
}
|
||||
mutableRunState.value = RunState.STOPPING
|
||||
runTimeMillis = ServiceController.stop()
|
||||
mutableRunState.value = RunState.STOPPED
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
package com.follow.clash
|
||||
|
||||
import android.net.VpnService
|
||||
import com.follow.clash.common.GlobalState
|
||||
import com.follow.clash.models.SharedState
|
||||
import com.follow.clash.plugins.AppPlugin
|
||||
import com.follow.clash.plugins.TilePlugin
|
||||
import com.follow.clash.service.models.NotificationParams
|
||||
import com.google.gson.Gson
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
enum class RunState {
|
||||
START, PENDING, STOP
|
||||
}
|
||||
|
||||
|
||||
object State {
|
||||
|
||||
val runLock = Mutex()
|
||||
|
||||
var runTime: Long = 0
|
||||
|
||||
var sharedState: SharedState = SharedState()
|
||||
|
||||
val runStateFlow: MutableStateFlow<RunState> = MutableStateFlow(RunState.STOP)
|
||||
|
||||
var flutterEngine: FlutterEngine? = null
|
||||
|
||||
val appPlugin: AppPlugin?
|
||||
get() = flutterEngine?.plugin<AppPlugin>()
|
||||
|
||||
val tilePlugin: TilePlugin?
|
||||
get() = flutterEngine?.plugin<TilePlugin>()
|
||||
|
||||
suspend fun handleToggleAction() {
|
||||
var action: (suspend () -> Unit)?
|
||||
runLock.withLock {
|
||||
action = when (runStateFlow.value) {
|
||||
RunState.PENDING -> null
|
||||
RunState.START -> ::handleStopServiceAction
|
||||
RunState.STOP -> ::handleStartServiceAction
|
||||
}
|
||||
}
|
||||
action?.invoke()
|
||||
}
|
||||
|
||||
suspend fun handleSyncState() {
|
||||
runLock.withLock {
|
||||
try {
|
||||
Service.bind()
|
||||
runTime = Service.getRunTime()
|
||||
val runState = when (runTime == 0L) {
|
||||
true -> RunState.STOP
|
||||
false -> RunState.START
|
||||
}
|
||||
runStateFlow.tryEmit(runState)
|
||||
} catch (_: Exception) {
|
||||
runStateFlow.tryEmit(RunState.STOP)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun handleStartServiceAction() {
|
||||
runLock.withLock {
|
||||
if (runStateFlow.value != RunState.STOP) {
|
||||
return
|
||||
}
|
||||
tilePlugin?.handleStart()
|
||||
if (flutterEngine != null) {
|
||||
return
|
||||
}
|
||||
startServiceWithPref()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
suspend fun handleStopServiceAction() {
|
||||
runLock.withLock {
|
||||
if (runStateFlow.value != RunState.START) {
|
||||
return
|
||||
}
|
||||
tilePlugin?.handleStop()
|
||||
if (flutterEngine != null) {
|
||||
return
|
||||
}
|
||||
GlobalState.application.showToast(sharedState.stopTip)
|
||||
handleStopService()
|
||||
}
|
||||
}
|
||||
|
||||
fun handleStartService() {
|
||||
val appPlugin = flutterEngine?.plugin<AppPlugin>()
|
||||
if (appPlugin != null) {
|
||||
appPlugin.requestNotificationsPermission {
|
||||
startService()
|
||||
}
|
||||
return
|
||||
}
|
||||
startService()
|
||||
}
|
||||
|
||||
private fun startServiceWithPref() {
|
||||
GlobalState.launch {
|
||||
runLock.withLock {
|
||||
if (runStateFlow.value != RunState.STOP) {
|
||||
return@launch
|
||||
}
|
||||
sharedState = GlobalState.application.sharedState
|
||||
setupAndStart()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun syncState() {
|
||||
GlobalState.setCrashlytics(sharedState.crashlytics)
|
||||
Service.updateNotificationParams(
|
||||
NotificationParams(
|
||||
title = sharedState.currentProfileName,
|
||||
stopText = sharedState.stopText,
|
||||
onlyStatisticsProxy = sharedState.onlyStatisticsProxy
|
||||
)
|
||||
)
|
||||
Service.setCrashlytics(sharedState.crashlytics)
|
||||
}
|
||||
|
||||
private suspend fun setupAndStart() {
|
||||
Service.bind()
|
||||
syncState()
|
||||
GlobalState.application.showToast(sharedState.startTip)
|
||||
val initParams = mutableMapOf<String, Any>()
|
||||
initParams["home-dir"] = GlobalState.application.filesDir.path
|
||||
initParams["version"] = android.os.Build.VERSION.SDK_INT
|
||||
val initParamsString = Gson().toJson(initParams)
|
||||
val setupParamsString = Gson().toJson(sharedState.setupParams)
|
||||
Service.quickSetup(
|
||||
initParamsString,
|
||||
setupParamsString,
|
||||
onStarted = {
|
||||
startService()
|
||||
},
|
||||
onResult = {
|
||||
if (it.isNotEmpty()) {
|
||||
GlobalState.application.showToast(it)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun startService() {
|
||||
GlobalState.launch {
|
||||
runLock.withLock {
|
||||
if (runStateFlow.value != RunState.STOP) {
|
||||
return@launch
|
||||
}
|
||||
try {
|
||||
runStateFlow.tryEmit(RunState.PENDING)
|
||||
val options = sharedState.vpnOptions ?: return@launch
|
||||
appPlugin?.let {
|
||||
it.prepare(options.enable) {
|
||||
runTime = Service.startService(options, runTime)
|
||||
runStateFlow.tryEmit(RunState.START)
|
||||
}
|
||||
} ?: run {
|
||||
val intent = VpnService.prepare(GlobalState.application)
|
||||
if (intent != null) {
|
||||
return@launch
|
||||
}
|
||||
runTime = Service.startService(options, runTime)
|
||||
runStateFlow.tryEmit(RunState.START)
|
||||
}
|
||||
} finally {
|
||||
if (runStateFlow.value == RunState.PENDING) {
|
||||
runStateFlow.tryEmit(RunState.STOP)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun handleStopService() {
|
||||
GlobalState.launch {
|
||||
runLock.withLock {
|
||||
if (runStateFlow.value != RunState.START) {
|
||||
return@launch
|
||||
}
|
||||
try {
|
||||
runStateFlow.tryEmit(RunState.PENDING)
|
||||
runTime = Service.stopService()
|
||||
runStateFlow.tryEmit(RunState.STOP)
|
||||
} finally {
|
||||
if (runStateFlow.value == RunState.PENDING) {
|
||||
runStateFlow.tryEmit(RunState.START)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
package com.follow.clash
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Bundle
|
||||
import com.follow.clash.common.QuickAction
|
||||
import com.follow.clash.common.action
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class TempActivity : Activity(),
|
||||
CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
when (intent.action) {
|
||||
QuickAction.START.action -> {
|
||||
launch {
|
||||
State.handleStartServiceAction()
|
||||
}
|
||||
}
|
||||
|
||||
QuickAction.STOP.action -> {
|
||||
launch {
|
||||
State.handleStopServiceAction()
|
||||
}
|
||||
}
|
||||
|
||||
QuickAction.TOGGLE.action -> {
|
||||
launch {
|
||||
State.handleToggleAction()
|
||||
}
|
||||
}
|
||||
}
|
||||
finish()
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package com.follow.clash
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Build
|
||||
import android.service.quicksettings.Tile
|
||||
import android.service.quicksettings.TileService
|
||||
import com.follow.clash.common.QuickAction
|
||||
import com.follow.clash.common.quickIntent
|
||||
import com.follow.clash.common.toPendingIntent
|
||||
@@ -13,49 +12,50 @@ import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class TileService : TileService() {
|
||||
class TileService : android.service.quicksettings.TileService() {
|
||||
private var scope: CoroutineScope? = null
|
||||
private fun updateTile(runState: RunState) {
|
||||
if (qsTile != null) {
|
||||
qsTile.state = when (runState) {
|
||||
RunState.START -> Tile.STATE_ACTIVE
|
||||
RunState.PENDING -> Tile.STATE_UNAVAILABLE
|
||||
RunState.STOP -> Tile.STATE_INACTIVE
|
||||
}
|
||||
qsTile.updateTile()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartListening() {
|
||||
super.onStartListening()
|
||||
scope?.cancel()
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
scope?.launch {
|
||||
State.handleSyncState()
|
||||
State.runStateFlow.collect {
|
||||
updateTile(it)
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate).also { scope ->
|
||||
scope.launch {
|
||||
ServiceState.refresh()
|
||||
ServiceState.runState.collect(::updateTile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("StartActivityAndCollapseDeprecated")
|
||||
private fun handleToggle() {
|
||||
val intent = QuickAction.TOGGLE.quickIntent
|
||||
val pendingIntent = intent.toPendingIntent
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
startActivityAndCollapse(pendingIntent)
|
||||
} else {
|
||||
@Suppress("DEPRECATION") startActivityAndCollapse(intent)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClick() {
|
||||
super.onClick()
|
||||
handleToggle()
|
||||
openQuickAction()
|
||||
}
|
||||
|
||||
override fun onStopListening() {
|
||||
scope?.cancel()
|
||||
scope = null
|
||||
super.onStopListening()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateTile(runState: RunState) {
|
||||
qsTile?.apply {
|
||||
state = when (runState) {
|
||||
RunState.STARTED -> Tile.STATE_ACTIVE
|
||||
RunState.STARTING, RunState.STOPPING -> Tile.STATE_UNAVAILABLE
|
||||
RunState.STOPPED -> Tile.STATE_INACTIVE
|
||||
}
|
||||
updateTile()
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("StartActivityAndCollapseDeprecated")
|
||||
private fun openQuickAction() {
|
||||
val intent = QuickAction.TOGGLE.quickIntent
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
startActivityAndCollapse(intent.toPendingIntent)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
startActivityAndCollapse(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-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,
|
||||
@@ -0,0 +1,174 @@
|
||||
package com.follow.clash.packages
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.ComponentInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import com.android.tools.smali.dexlib2.dexbacked.DexBackedDexFile
|
||||
import com.follow.clash.models.InstalledPackage
|
||||
import java.io.File
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
internal class PackageResolver(
|
||||
private val packageManager: PackageManager,
|
||||
private val appPackageName: String,
|
||||
) {
|
||||
val installedPackages: List<InstalledPackage> by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
|
||||
loadPackages()
|
||||
}
|
||||
|
||||
fun getChinaPackageNames(): List<String> = installedPackages
|
||||
.map { it.packageName }
|
||||
.filter(::isChinaPackage)
|
||||
|
||||
private fun loadPackages(): List<InstalledPackage> {
|
||||
val flags = PackageManager.GET_PERMISSIONS
|
||||
val packages = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
packageManager.getInstalledPackages(
|
||||
PackageManager.PackageInfoFlags.of(flags.toLong()),
|
||||
)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
packageManager.getInstalledPackages(flags)
|
||||
}
|
||||
return packages.asSequence()
|
||||
.filter { info ->
|
||||
info.packageName != appPackageName && info.packageName != ANDROID_PACKAGE_NAME
|
||||
}
|
||||
.map { info ->
|
||||
InstalledPackage(
|
||||
packageName = info.packageName,
|
||||
label = info.applicationInfo?.loadLabel(packageManager)?.toString()
|
||||
?: info.packageName,
|
||||
system = info.applicationInfo?.let { applicationInfo ->
|
||||
applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM != 0
|
||||
} == true,
|
||||
internet = info.requestedPermissions
|
||||
?.contains(Manifest.permission.INTERNET) == true,
|
||||
lastUpdateTime = info.lastUpdateTime,
|
||||
)
|
||||
}.toList()
|
||||
}
|
||||
|
||||
private fun isChinaPackage(packageName: String): Boolean {
|
||||
if (SKIPPED_PREFIXES.any { packageName == it || packageName.startsWith("$it.") }) {
|
||||
return false
|
||||
}
|
||||
if (packageName.matches(CHINA_PACKAGE_REGEX)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return runCatching {
|
||||
val packageInfo = getPackageInfo(packageName)
|
||||
packageInfo.componentNames().any { it.matches(CHINA_PACKAGE_REGEX) } ||
|
||||
packageInfo.applicationInfo?.publicSourceDir?.let(::scanArchive) == true
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
private fun getPackageInfo(packageName: String) = if (
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
|
||||
) {
|
||||
packageManager.getPackageInfo(
|
||||
packageName,
|
||||
PackageManager.PackageInfoFlags.of(PACKAGE_INFO_FLAGS.toLong()),
|
||||
)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
packageManager.getPackageInfo(packageName, PACKAGE_INFO_FLAGS)
|
||||
}
|
||||
|
||||
private fun android.content.pm.PackageInfo.componentNames(): Sequence<String> = sequence {
|
||||
yieldAll(services.orEmpty().asSequence().map(ComponentInfo::name))
|
||||
yieldAll(activities.orEmpty().asSequence().map(ComponentInfo::name))
|
||||
yieldAll(receivers.orEmpty().asSequence().map(ComponentInfo::name))
|
||||
yieldAll(providers.orEmpty().asSequence().map(ComponentInfo::name))
|
||||
}
|
||||
|
||||
private fun scanArchive(sourcePath: String): Boolean = ZipFile(File(sourcePath)).use { archive ->
|
||||
if (archive.entries().asSequence().any { it.name.startsWith("firebase-") }) {
|
||||
return false
|
||||
}
|
||||
archive.entries().asSequence()
|
||||
.filter { entry ->
|
||||
entry.name.startsWith("classes") && entry.name.endsWith(".dex")
|
||||
}.any { entry ->
|
||||
if (entry.size > MAX_DEX_SIZE_BYTES) {
|
||||
return@any true
|
||||
}
|
||||
val dexFile = archive.getInputStream(entry).buffered().use { input ->
|
||||
DexBackedDexFile.fromInputStream(null, input)
|
||||
}
|
||||
dexFile.classes.any { clazz ->
|
||||
clazz.type
|
||||
.removeSurrounding("L", ";")
|
||||
.replace('/', '.')
|
||||
.replace('$', '.')
|
||||
.matches(CHINA_PACKAGE_REGEX)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val ANDROID_PACKAGE_NAME = "android"
|
||||
const val MAX_DEX_SIZE_BYTES = 15_000_000L
|
||||
|
||||
val PACKAGE_INFO_FLAGS = PackageManager.GET_ACTIVITIES or
|
||||
PackageManager.GET_SERVICES or
|
||||
PackageManager.GET_RECEIVERS or
|
||||
PackageManager.GET_PROVIDERS
|
||||
|
||||
val SKIPPED_PREFIXES = listOf(
|
||||
"com.google",
|
||||
"com.android.chrome",
|
||||
"com.android.vending",
|
||||
"com.microsoft",
|
||||
"com.apple",
|
||||
"com.zhiliaoapp.musically",
|
||||
)
|
||||
|
||||
val CHINA_PACKAGE_REGEX = listOf(
|
||||
"com.tencent",
|
||||
"com.alibaba",
|
||||
"com.umeng",
|
||||
"com.qihoo",
|
||||
"com.ali",
|
||||
"com.alipay",
|
||||
"com.amap",
|
||||
"com.sina",
|
||||
"com.weibo",
|
||||
"com.vivo",
|
||||
"com.xiaomi",
|
||||
"com.huawei",
|
||||
"com.taobao",
|
||||
"com.secneo",
|
||||
"s.h.e.l.l",
|
||||
"com.stub",
|
||||
"com.kiwisec",
|
||||
"com.secshell",
|
||||
"com.wrapper",
|
||||
"cn.securitystack",
|
||||
"com.mogosec",
|
||||
"com.secoen",
|
||||
"com.netease",
|
||||
"com.mx",
|
||||
"com.qq.e",
|
||||
"com.baidu",
|
||||
"com.bytedance",
|
||||
"com.bugly",
|
||||
"com.miui",
|
||||
"com.oppo",
|
||||
"com.coloros",
|
||||
"com.iqoo",
|
||||
"com.meizu",
|
||||
"com.gionee",
|
||||
"cn.nubia",
|
||||
"com.oplus",
|
||||
"andes.oplus",
|
||||
"com.unionpay",
|
||||
"cn.wps",
|
||||
).joinToString("|", prefix = "(", postfix = ").*") { prefix ->
|
||||
Regex.escape(prefix)
|
||||
}.toRegex()
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
package com.follow.clash.plugins
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.app.ActivityManager
|
||||
import android.content.Intent
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.ComponentInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.VpnService
|
||||
import android.os.Build
|
||||
@@ -18,17 +17,15 @@ import androidx.core.content.pm.ShortcutInfoCompat
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.core.net.toUri
|
||||
import com.android.tools.smali.dexlib2.dexbacked.DexBackedDexFile
|
||||
import com.follow.clash.R
|
||||
import com.follow.clash.common.Components
|
||||
import com.follow.clash.common.GlobalState
|
||||
import com.follow.clash.common.QuickAction
|
||||
import com.follow.clash.common.quickIntent
|
||||
import com.follow.clash.getPackageIconPath
|
||||
import com.follow.clash.models.Package
|
||||
import com.follow.clash.packages.PackageResolver
|
||||
import com.follow.clash.showToast
|
||||
import com.google.gson.Gson
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||
@@ -37,93 +34,37 @@ import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.MethodChannel.Result
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware {
|
||||
|
||||
companion object {
|
||||
const val VPN_PERMISSION_REQUEST_CODE = 1001
|
||||
const val NOTIFICATION_PERMISSION_REQUEST_CODE = 1002
|
||||
}
|
||||
|
||||
private var activityRef: WeakReference<Activity>? = null
|
||||
private var activity: Activity? = null
|
||||
|
||||
private lateinit var channel: MethodChannel
|
||||
|
||||
private lateinit var scope: CoroutineScope
|
||||
|
||||
private var vpnPrepareCallback: (suspend () -> Unit)? = null
|
||||
private var vpnPrepareCallback: ((Boolean) -> Unit)? = null
|
||||
|
||||
private var requestNotificationCallback: (() -> Unit)? = null
|
||||
|
||||
private val packages = mutableListOf<Package>()
|
||||
private val gson = Gson()
|
||||
|
||||
private val skipPrefixList = listOf(
|
||||
"com.google",
|
||||
"com.android.chrome",
|
||||
"com.android.vending",
|
||||
"com.microsoft",
|
||||
"com.apple",
|
||||
"com.zhiliaoapp.musically", // Banned by China
|
||||
)
|
||||
|
||||
private val chinaAppPrefixList = listOf(
|
||||
"com.tencent",
|
||||
"com.alibaba",
|
||||
"com.umeng",
|
||||
"com.qihoo",
|
||||
"com.ali",
|
||||
"com.alipay",
|
||||
"com.amap",
|
||||
"com.sina",
|
||||
"com.weibo",
|
||||
"com.vivo",
|
||||
"com.xiaomi",
|
||||
"com.huawei",
|
||||
"com.taobao",
|
||||
"com.secneo",
|
||||
"s.h.e.l.l",
|
||||
"com.stub",
|
||||
"com.kiwisec",
|
||||
"com.secshell",
|
||||
"com.wrapper",
|
||||
"cn.securitystack",
|
||||
"com.mogosec",
|
||||
"com.secoen",
|
||||
"com.netease",
|
||||
"com.mx",
|
||||
"com.qq.e",
|
||||
"com.baidu",
|
||||
"com.bytedance",
|
||||
"com.bugly",
|
||||
"com.miui",
|
||||
"com.oppo",
|
||||
"com.coloros",
|
||||
"com.iqoo",
|
||||
"com.meizu",
|
||||
"com.gionee",
|
||||
"cn.nubia",
|
||||
"com.oplus",
|
||||
"andes.oplus",
|
||||
"com.unionpay",
|
||||
"cn.wps"
|
||||
)
|
||||
|
||||
private val chinaAppRegex by lazy {
|
||||
("(" + chinaAppPrefixList.joinToString("|").replace(".", "\\.") + ").*").toRegex()
|
||||
private val packageResolver by lazy {
|
||||
PackageResolver(
|
||||
GlobalState.application.packageManager,
|
||||
GlobalState.application.packageName,
|
||||
)
|
||||
}
|
||||
|
||||
private var isBlockNotification: Boolean = false
|
||||
private var skipNotificationPermissionRequest = false
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: Result) {
|
||||
when (call.method) {
|
||||
"moveTaskToBack" -> {
|
||||
activityRef?.get()?.moveTaskToBack(true)
|
||||
activity?.moveTaskToBack(true)
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
@@ -134,19 +75,24 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
||||
}
|
||||
|
||||
"initShortcuts" -> {
|
||||
initShortcuts(call.arguments as String)
|
||||
result.success(true)
|
||||
val label = call.arguments as? String
|
||||
if (label == null) {
|
||||
result.error("INVALID_ARGUMENT", "Shortcut label must be a string", null)
|
||||
} else {
|
||||
initShortcuts(label)
|
||||
result.success(true)
|
||||
}
|
||||
}
|
||||
|
||||
"getPackages" -> {
|
||||
scope.launch {
|
||||
result.success(getPackagesToJson())
|
||||
scope.launch(Dispatchers.IO) {
|
||||
result.success(gson.toJson(packageResolver.installedPackages))
|
||||
}
|
||||
}
|
||||
|
||||
"getChinaPackageNames" -> {
|
||||
scope.launch {
|
||||
result.success(getChinaPackageNames())
|
||||
scope.launch(Dispatchers.IO) {
|
||||
result.success(gson.toJson(packageResolver.getChinaPackageNames()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,7 +102,7 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
||||
|
||||
"tip" -> {
|
||||
val message = call.argument<String>("message")
|
||||
tip(message)
|
||||
GlobalState.application.showToast(message)
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
@@ -172,6 +118,10 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
||||
result.success(openAppSettings())
|
||||
}
|
||||
|
||||
"didCrashOnPreviousExecution" -> {
|
||||
result.success(GlobalState.didCrashOnPreviousExecution())
|
||||
}
|
||||
|
||||
else -> {
|
||||
result.notImplemented()
|
||||
}
|
||||
@@ -197,32 +147,32 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
||||
IconCompat.createWithResource(
|
||||
GlobalState.application,
|
||||
R.mipmap.ic_launcher_round,
|
||||
)
|
||||
),
|
||||
)
|
||||
setIntent(QuickAction.TOGGLE.quickIntent)
|
||||
build()
|
||||
}
|
||||
ShortcutManagerCompat.setDynamicShortcuts(
|
||||
GlobalState.application, listOf(shortcut)
|
||||
GlobalState.application,
|
||||
listOf(shortcut),
|
||||
)
|
||||
}
|
||||
|
||||
private fun tip(message: String?) {
|
||||
GlobalState.application.showToast(message)
|
||||
}
|
||||
|
||||
private fun isBatteryOptimizationDisabled(): Boolean {
|
||||
val powerManager = getSystemService(GlobalState.application, PowerManager::class.java)
|
||||
return powerManager?.isIgnoringBatteryOptimizations(GlobalState.application.packageName)
|
||||
?: false
|
||||
}
|
||||
|
||||
@SuppressLint("BatteryLife")
|
||||
private fun openBatteryOptimizationSettings(): Boolean {
|
||||
// VPN continuity is the user-requested core function, so the direct exemption is intentional.
|
||||
val activity = activity ?: return false
|
||||
return try {
|
||||
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
|
||||
data = "package:${GlobalState.application.packageName}".toUri()
|
||||
}
|
||||
activityRef?.get()?.startActivity(intent)
|
||||
activity.startActivity(intent)
|
||||
true
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
@@ -230,11 +180,12 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
||||
}
|
||||
|
||||
private fun openAppSettings(): Boolean {
|
||||
val activity = activity ?: return false
|
||||
return try {
|
||||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = "package:${GlobalState.application.packageName}".toUri()
|
||||
}
|
||||
activityRef?.get()?.startActivity(intent)
|
||||
activity.startActivity(intent)
|
||||
true
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
@@ -246,170 +197,69 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
||||
val am = getSystemService(GlobalState.application, ActivityManager::class.java)
|
||||
val task = am?.appTasks?.firstOrNull {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
it.taskInfo.taskId == activityRef?.get()?.taskId
|
||||
it.taskInfo.taskId == activity?.taskId
|
||||
} else {
|
||||
it.taskInfo.id == activityRef?.get()?.taskId
|
||||
it.taskInfo.id == activity?.taskId
|
||||
}
|
||||
}
|
||||
|
||||
when (value) {
|
||||
true -> task?.setExcludeFromRecents(value)
|
||||
false -> task?.setExcludeFromRecents(value)
|
||||
null -> task?.setExcludeFromRecents(false)
|
||||
}
|
||||
task?.setExcludeFromRecents(value ?: false)
|
||||
}
|
||||
|
||||
|
||||
private fun getPackages(): List<Package> {
|
||||
val packageManager = GlobalState.application.packageManager
|
||||
if (packages.isNotEmpty()) return packages
|
||||
packageManager?.getInstalledPackages(PackageManager.GET_META_DATA or PackageManager.GET_PERMISSIONS)
|
||||
?.filter {
|
||||
it.packageName != GlobalState.application.packageName && it.packageName != "android"
|
||||
}?.map {
|
||||
Package(
|
||||
packageName = it.packageName,
|
||||
label = it.applicationInfo?.loadLabel(packageManager).toString(),
|
||||
system = (it.applicationInfo?.flags?.and(ApplicationInfo.FLAG_SYSTEM)) != 0,
|
||||
lastUpdateTime = it.lastUpdateTime,
|
||||
internet = it.requestedPermissions?.contains(Manifest.permission.INTERNET) == true
|
||||
)
|
||||
}?.let { packages.addAll(it) }
|
||||
return packages
|
||||
}
|
||||
|
||||
private suspend fun getPackagesToJson(): String {
|
||||
return withContext(Dispatchers.Default) {
|
||||
Gson().toJson(getPackages())
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getChinaPackageNames(): String {
|
||||
return withContext(Dispatchers.Default) {
|
||||
val packages: List<String> =
|
||||
getPackages().map { it.packageName }.filter { isChinaPackage(it) }
|
||||
Gson().toJson(packages)
|
||||
}
|
||||
}
|
||||
|
||||
fun requestNotificationsPermission(callBack: () -> Unit) {
|
||||
requestNotificationCallback = callBack
|
||||
fun requestNotificationPermission(callback: () -> Unit) {
|
||||
requestNotificationCallback = callback
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
val permission = ContextCompat.checkSelfPermission(
|
||||
GlobalState.application, Manifest.permission.POST_NOTIFICATIONS
|
||||
GlobalState.application,
|
||||
Manifest.permission.POST_NOTIFICATIONS,
|
||||
)
|
||||
if (permission == PackageManager.PERMISSION_GRANTED || isBlockNotification) {
|
||||
if (permission == PackageManager.PERMISSION_GRANTED || skipNotificationPermissionRequest) {
|
||||
invokeRequestNotificationCallback()
|
||||
return
|
||||
}
|
||||
activityRef?.get()?.let {
|
||||
activity?.let {
|
||||
ActivityCompat.requestPermissions(
|
||||
it,
|
||||
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||
NOTIFICATION_PERMISSION_REQUEST_CODE
|
||||
NOTIFICATION_PERMISSION_REQUEST_CODE,
|
||||
)
|
||||
}
|
||||
} ?: invokeRequestNotificationCallback()
|
||||
return
|
||||
} else {
|
||||
invokeRequestNotificationCallback()
|
||||
}
|
||||
|
||||
invokeRequestNotificationCallback()
|
||||
}
|
||||
|
||||
fun invokeRequestNotificationCallback() {
|
||||
private fun invokeRequestNotificationCallback() {
|
||||
requestNotificationCallback?.invoke()
|
||||
requestNotificationCallback = null
|
||||
}
|
||||
|
||||
fun prepare(needPrepare: Boolean, callBack: (suspend () -> Unit)) {
|
||||
vpnPrepareCallback = callBack
|
||||
fun prepareVpn(needPrepare: Boolean, callback: (Boolean) -> Unit) {
|
||||
vpnPrepareCallback = callback
|
||||
if (!needPrepare) {
|
||||
invokeVpnPrepareCallback()
|
||||
invokeVpnPrepareCallback(true)
|
||||
return
|
||||
}
|
||||
val intent = VpnService.prepare(GlobalState.application)
|
||||
if (intent != null) {
|
||||
activityRef?.get()?.startActivityForResult(intent, VPN_PERMISSION_REQUEST_CODE)
|
||||
val activity = activity
|
||||
if (activity == null) {
|
||||
invokeVpnPrepareCallback(false)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
activity.startActivityForResult(intent, VPN_PERMISSION_REQUEST_CODE)
|
||||
}
|
||||
return
|
||||
}
|
||||
invokeVpnPrepareCallback()
|
||||
invokeVpnPrepareCallback(true)
|
||||
}
|
||||
|
||||
fun invokeVpnPrepareCallback() {
|
||||
GlobalState.launch {
|
||||
vpnPrepareCallback?.invoke()
|
||||
vpnPrepareCallback = null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun isChinaPackage(packageName: String): Boolean {
|
||||
val packageManager = GlobalState.application.packageManager ?: return false
|
||||
skipPrefixList.forEach {
|
||||
if (packageName == it || packageName.startsWith("$it.")) return false
|
||||
}
|
||||
val packageManagerFlags =
|
||||
PackageManager.MATCH_UNINSTALLED_PACKAGES or PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES or PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS
|
||||
if (packageName.matches(chinaAppRegex)) {
|
||||
return true
|
||||
}
|
||||
try {
|
||||
val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
packageManager.getPackageInfo(
|
||||
packageName, PackageManager.PackageInfoFlags.of(packageManagerFlags.toLong())
|
||||
)
|
||||
} else {
|
||||
packageManager.getPackageInfo(
|
||||
packageName, packageManagerFlags
|
||||
)
|
||||
}
|
||||
mutableListOf<ComponentInfo>().apply {
|
||||
packageInfo.services?.let { addAll(it) }
|
||||
packageInfo.activities?.let { addAll(it) }
|
||||
packageInfo.receivers?.let { addAll(it) }
|
||||
packageInfo.providers?.let { addAll(it) }
|
||||
}.forEach {
|
||||
if (it.name.matches(chinaAppRegex)) return true
|
||||
}
|
||||
packageInfo.applicationInfo?.publicSourceDir?.let {
|
||||
ZipFile(File(it)).use {
|
||||
for (packageEntry in it.entries()) {
|
||||
if (packageEntry.name.startsWith("firebase-")) return false
|
||||
}
|
||||
for (packageEntry in it.entries()) {
|
||||
if (!(packageEntry.name.startsWith("classes") && packageEntry.name.endsWith(
|
||||
".dex"
|
||||
))
|
||||
) {
|
||||
continue
|
||||
}
|
||||
if (packageEntry.size > 15000000) {
|
||||
return true
|
||||
}
|
||||
val input = it.getInputStream(packageEntry).buffered()
|
||||
val dexFile = try {
|
||||
DexBackedDexFile.fromInputStream(null, input)
|
||||
} catch (e: Exception) {
|
||||
return false
|
||||
}
|
||||
for (clazz in dexFile.classes) {
|
||||
val clazzName =
|
||||
clazz.type.substring(1, clazz.type.length - 1).replace("/", ".")
|
||||
.replace("$", ".")
|
||||
if (clazzName.matches(chinaAppRegex)) return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
return false
|
||||
}
|
||||
return false
|
||||
private fun invokeVpnPrepareCallback(granted: Boolean) {
|
||||
vpnPrepareCallback?.invoke(granted)
|
||||
vpnPrepareCallback = null
|
||||
}
|
||||
|
||||
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
|
||||
scope = CoroutineScope(Dispatchers.Default)
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
channel =
|
||||
MethodChannel(flutterPluginBinding.binaryMessenger, "${Components.PACKAGE_NAME}/app")
|
||||
channel.setMethodCallHandler(this)
|
||||
@@ -418,43 +268,58 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
channel.setMethodCallHandler(null)
|
||||
scope.cancel()
|
||||
vpnPrepareCallback = null
|
||||
requestNotificationCallback = null
|
||||
}
|
||||
|
||||
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||
activityRef = WeakReference(binding.activity)
|
||||
attachToActivity(binding)
|
||||
}
|
||||
|
||||
private fun attachToActivity(binding: ActivityPluginBinding) {
|
||||
activity = binding.activity
|
||||
binding.addActivityResultListener(::onActivityResult)
|
||||
binding.addRequestPermissionsResultListener(::onRequestPermissionsResultListener)
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivityForConfigChanges() {
|
||||
activityRef = null
|
||||
activity = null
|
||||
}
|
||||
|
||||
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
|
||||
activityRef = WeakReference(binding.activity)
|
||||
attachToActivity(binding)
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivity() {
|
||||
channel.invokeMethod("exit", null)
|
||||
activityRef = null
|
||||
activity = null
|
||||
vpnPrepareCallback = null
|
||||
requestNotificationCallback = null
|
||||
}
|
||||
|
||||
private fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
|
||||
if (requestCode == VPN_PERMISSION_REQUEST_CODE) {
|
||||
if (resultCode == FlutterActivity.RESULT_OK) {
|
||||
invokeVpnPrepareCallback()
|
||||
}
|
||||
if (requestCode != VPN_PERMISSION_REQUEST_CODE) {
|
||||
return false
|
||||
}
|
||||
invokeVpnPrepareCallback(resultCode == Activity.RESULT_OK)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun onRequestPermissionsResultListener(
|
||||
requestCode: Int, permissions: Array<String>, grantResults: IntArray
|
||||
requestCode: Int,
|
||||
permissions: Array<String>,
|
||||
grantResults: IntArray,
|
||||
): Boolean {
|
||||
if (requestCode == NOTIFICATION_PERMISSION_REQUEST_CODE) {
|
||||
isBlockNotification = true
|
||||
if (requestCode != NOTIFICATION_PERMISSION_REQUEST_CODE) {
|
||||
return false
|
||||
}
|
||||
skipNotificationPermissionRequest = true
|
||||
invokeRequestNotificationCallback()
|
||||
return true
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val VPN_PERMISSION_REQUEST_CODE = 1001
|
||||
const val NOTIFICATION_PERMISSION_REQUEST_CODE = 1002
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
package com.follow.clash.plugins
|
||||
|
||||
import com.follow.clash.RunState
|
||||
import com.follow.clash.Service
|
||||
import com.follow.clash.State
|
||||
import com.follow.clash.ServiceController
|
||||
import com.follow.clash.ServiceState
|
||||
import com.follow.clash.common.Components
|
||||
import com.follow.clash.invokeMethodOnMainThread
|
||||
import com.follow.clash.models.SharedState
|
||||
@@ -13,127 +12,109 @@ 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.setServiceDisconnectedListener(null)
|
||||
ServiceController.setEventListener(null)
|
||||
}
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) = when (call.method) {
|
||||
"init" -> {
|
||||
handleInit(result)
|
||||
}
|
||||
|
||||
"shutdown" -> {
|
||||
handleShutdown(result)
|
||||
}
|
||||
|
||||
"invokeAction" -> {
|
||||
handleInvokeAction(call, result)
|
||||
}
|
||||
|
||||
"getRunTime" -> {
|
||||
handleGetRunTime(result)
|
||||
}
|
||||
|
||||
"syncState" -> {
|
||||
handleSyncState(call, result)
|
||||
}
|
||||
|
||||
"start" -> {
|
||||
handleStart(result)
|
||||
}
|
||||
|
||||
"stop" -> {
|
||||
handleStop(result)
|
||||
}
|
||||
|
||||
else -> {
|
||||
result.notImplemented()
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"init" -> initialize(result)
|
||||
"shutdown" -> shutdown(result)
|
||||
"invokeMethod" -> invokeMethod(call, result)
|
||||
"getRunTime" -> getRunTime(result)
|
||||
"syncState" -> syncState(call, result)
|
||||
"start" -> start(result)
|
||||
"stop" -> stop(result)
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleInvokeAction(call: MethodCall, result: MethodChannel.Result) {
|
||||
launch {
|
||||
val data = call.arguments<String>()!!
|
||||
Service.invokeAction(data) {
|
||||
result.success(it)
|
||||
private fun initialize(result: MethodChannel.Result) {
|
||||
ServiceController.setServiceDisconnectedListener(::onServiceDisconnected)
|
||||
ServiceController.setEventListener(::sendEvent)
|
||||
.onSuccess { result.success("") }
|
||||
.onFailure { error -> result.success(error.message.orEmpty()) }
|
||||
}
|
||||
|
||||
private fun shutdown(result: MethodChannel.Result) {
|
||||
scope.launch {
|
||||
ServiceController.unbind()
|
||||
result.success(true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun invokeMethod(call: MethodCall, result: MethodChannel.Result) {
|
||||
val data = call.arguments as? String
|
||||
if (data == null) {
|
||||
result.error("INVALID_ARGUMENT", "Method call payload must be a string", null)
|
||||
return
|
||||
}
|
||||
scope.launch {
|
||||
ServiceController.invokeMethod(data) { response ->
|
||||
result.success(response)
|
||||
}.onFailure { error ->
|
||||
result.error("CORE_ERROR", error.message, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleShutdown(result: MethodChannel.Result) {
|
||||
Service.unbind()
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
private fun handleStart(result: MethodChannel.Result) {
|
||||
State.handleStartService()
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
private fun handleStop(result: MethodChannel.Result) {
|
||||
State.handleStopService()
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
val semaphore = Semaphore(10)
|
||||
|
||||
fun handleSendEvent(value: String?) {
|
||||
launch(Dispatchers.Main) {
|
||||
semaphore.withPermit {
|
||||
flutterMethodChannel.invokeMethod("event", value)
|
||||
}
|
||||
private fun getRunTime(result: MethodChannel.Result) {
|
||||
scope.launch {
|
||||
ServiceState.refresh()
|
||||
result.success(ServiceState.runTimeMillis)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onServiceDisconnected(message: String) {
|
||||
State.runStateFlow.tryEmit(RunState.STOP)
|
||||
flutterMethodChannel.invokeMethodOnMainThread<Any>("crash", message)
|
||||
}
|
||||
|
||||
private fun handleSyncState(call: MethodCall, result: MethodChannel.Result) {
|
||||
val data = call.arguments<String>()!!
|
||||
State.sharedState = Gson().fromJson(data, SharedState::class.java)
|
||||
launch {
|
||||
State.syncState()
|
||||
private fun syncState(call: MethodCall, result: MethodChannel.Result) {
|
||||
val data = call.arguments as? String
|
||||
val state = runCatching {
|
||||
gson.fromJson(data, SharedState::class.java)
|
||||
}.getOrNull()
|
||||
if (state == null) {
|
||||
result.success("Invalid shared state")
|
||||
return
|
||||
}
|
||||
scope.launch {
|
||||
ServiceState.syncSharedState(state)
|
||||
result.success("")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun handleInit(result: MethodChannel.Result) {
|
||||
Service.bind()
|
||||
launch {
|
||||
Service.setEventListener {
|
||||
handleSendEvent(it)
|
||||
}.onSuccess {
|
||||
result.success("")
|
||||
}.onFailure {
|
||||
result.success(it.message)
|
||||
}
|
||||
|
||||
}
|
||||
Service.onServiceDisconnected = ::onServiceDisconnected
|
||||
private fun start(result: MethodChannel.Result) {
|
||||
ServiceState.requestStart()
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
private fun handleGetRunTime(result: MethodChannel.Result) {
|
||||
launch {
|
||||
State.handleSyncState()
|
||||
result.success(State.runTime)
|
||||
private fun stop(result: MethodChannel.Result) {
|
||||
ServiceState.requestStop()
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
private fun sendEvent(value: String?) {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
channel.invokeMethod("event", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onServiceDisconnected(message: String) {
|
||||
ServiceState.handleServiceDisconnected()
|
||||
channel.invokeMethodOnMainThread("crash", message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
class TilePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||
|
||||
private lateinit var channel: MethodChannel
|
||||
|
||||
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
|
||||
@@ -21,13 +20,14 @@ class TilePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||
}
|
||||
|
||||
fun handleStart() {
|
||||
channel.invokeMethodOnMainThread<Any>("start", null)
|
||||
channel.invokeMethodOnMainThread("start")
|
||||
}
|
||||
|
||||
fun handleStop() {
|
||||
channel.invokeMethodOnMainThread<Any>("stop", null)
|
||||
channel.invokeMethodOnMainThread("stop")
|
||||
}
|
||||
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {}
|
||||
}
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
<paths>
|
||||
<files-path
|
||||
name="files"
|
||||
path="."/>
|
||||
</paths>
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config xmlns:tools="http://schemas.android.com/tools"
|
||||
tools:ignore="AcceptsUserCertificates">
|
||||
<base-config>
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
<certificates src="user" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="true">localhost</domain>
|
||||
<domain includeSubdomains="true">127.0.0.1</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
@@ -1,7 +0,0 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
@@ -14,9 +14,6 @@ rootProject.layout.buildDirectory.value(newBuildDir)
|
||||
subprojects {
|
||||
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||
}
|
||||
|
||||
subprojects {
|
||||
project.evaluationDependsOn(":app")
|
||||
}
|
||||
|
||||
|
||||
@@ -9,24 +9,13 @@ android {
|
||||
compileSdk = libs.versions.compileSdk.get().toInt()
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 21
|
||||
consumerProguardFiles("consumer-rules.pro")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
minSdk = libs.versions.minSdk.get().toInt()
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
kotlin {
|
||||
|
||||
@@ -5,12 +5,12 @@ import android.content.ComponentName
|
||||
object Components {
|
||||
const val PACKAGE_NAME = "com.follow.clash"
|
||||
|
||||
val MAIN_ACTIVITY =
|
||||
val mainActivity =
|
||||
ComponentName(GlobalState.packageName, "${PACKAGE_NAME}.MainActivity")
|
||||
|
||||
val TEMP_ACTIVITY =
|
||||
ComponentName(GlobalState.packageName, "${PACKAGE_NAME}.TempActivity")
|
||||
val quickActionActivity =
|
||||
ComponentName(GlobalState.packageName, "${PACKAGE_NAME}.QuickActionActivity")
|
||||
|
||||
val BROADCAST_RECEIVER =
|
||||
ComponentName(GlobalState.packageName, "${PACKAGE_NAME}.BroadcastReceiver")
|
||||
}
|
||||
val serviceBroadcastReceiver =
|
||||
ComponentName(GlobalState.packageName, "${PACKAGE_NAME}.ServiceBroadcastReceiver")
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.follow.clash.common
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
|
||||
enum class QuickAction {
|
||||
STOP,
|
||||
START,
|
||||
@@ -20,4 +19,4 @@ enum class AccessControlMode {
|
||||
|
||||
@SerializedName("rejectSelected")
|
||||
REJECT_SELECTED,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,98 +13,60 @@ import android.content.Context
|
||||
import android.content.Context.RECEIVER_NOT_EXPORTED
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.ServiceConnection
|
||||
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.os.RemoteException
|
||||
import android.util.Log
|
||||
import androidx.core.content.getSystemService
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.retryWhen
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.nio.charset.Charset
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
//fun Context.startForegroundServiceCompat(intent: Intent?) {
|
||||
// if (Build.VERSION.SDK_INT >= 26) {
|
||||
// startForegroundService(intent)
|
||||
// } else {
|
||||
// startService(intent)
|
||||
// }
|
||||
//}
|
||||
|
||||
val KClass<*>.intent: Intent
|
||||
get() = Intent(GlobalState.application, this.java)
|
||||
|
||||
fun Service.startForegroundCompat(id: Int, notification: Notification) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
startForeground(id, notification, FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
|
||||
} else {
|
||||
startForeground(id, notification)
|
||||
}
|
||||
}
|
||||
|
||||
val ComponentName.intent: Intent
|
||||
get() = Intent().apply {
|
||||
setComponent(this@intent)
|
||||
setPackage(GlobalState.packageName)
|
||||
component = this@intent
|
||||
}
|
||||
|
||||
val QuickAction.action: String
|
||||
get() = "${GlobalState.application.packageName}.action.${this.name}"
|
||||
|
||||
val QuickAction.quickIntent: Intent
|
||||
get() = Components.TEMP_ACTIVITY.intent.apply {
|
||||
get() = Components.quickActionActivity.intent.apply {
|
||||
action = this@quickIntent.action
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
|
||||
val BroadcastAction.action: String
|
||||
get() = "${GlobalState.application.packageName}.intent.action.${this.name}"
|
||||
|
||||
val Context.processName: String?
|
||||
get() {
|
||||
val pid = android.os.Process.myPid()
|
||||
val activityManager = getSystemService<ActivityManager>()
|
||||
activityManager?.runningAppProcesses?.find { it.pid == pid }?.let {
|
||||
return it.processName
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
val BroadcastAction.quickIntent: Intent
|
||||
get() = Components.BROADCAST_RECEIVER.intent.apply {
|
||||
action = this@quickIntent.action
|
||||
}
|
||||
get() = getSystemService<ActivityManager>()
|
||||
?.runningAppProcesses
|
||||
?.firstOrNull { it.pid == android.os.Process.myPid() }
|
||||
?.processName
|
||||
|
||||
fun BroadcastAction.sendBroadcast() {
|
||||
val intent = Intent().apply {
|
||||
action = this@sendBroadcast.action
|
||||
Log.d("[sendBroadcast]", "$action")
|
||||
setPackage(GlobalState.packageName)
|
||||
val broadcastAction = action
|
||||
val intent = Intent(broadcastAction).apply {
|
||||
component = Components.serviceBroadcastReceiver
|
||||
}
|
||||
GlobalState.log("Send broadcast: $broadcastAction")
|
||||
GlobalState.application.sendBroadcast(
|
||||
intent, GlobalState.RECEIVE_BROADCASTS_PERMISSIONS
|
||||
intent,
|
||||
GlobalState.receiveBroadcastPermission,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
val Intent.toPendingIntent: PendingIntent
|
||||
get() = PendingIntent.getActivity(
|
||||
GlobalState.application,
|
||||
0,
|
||||
this,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
|
||||
|
||||
fun Service.startForeground(notification: Notification) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
@@ -112,13 +74,21 @@ fun Service.startForeground(notification: Notification) {
|
||||
if (channel == null) {
|
||||
channel = NotificationChannel(
|
||||
GlobalState.NOTIFICATION_CHANNEL,
|
||||
"SERVICE_CHANNEL",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
getString(R.string.service_channel_name),
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
)
|
||||
manager?.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
startForegroundCompat(GlobalState.NOTIFICATION_ID, notification)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
startForeground(
|
||||
GlobalState.NOTIFICATION_ID,
|
||||
notification,
|
||||
FOREGROUND_SERVICE_TYPE_SPECIAL_USE,
|
||||
)
|
||||
} else {
|
||||
startForeground(GlobalState.NOTIFICATION_ID, notification)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("UnspecifiedRegisterReceiverFlag")
|
||||
@@ -144,107 +114,3 @@ fun Context.receiveBroadcastFlow(
|
||||
registerReceiverCompat(receiver, filter)
|
||||
awaitClose { unregisterReceiver(receiver) }
|
||||
}
|
||||
|
||||
|
||||
inline fun <reified T : IBinder> Context.bindServiceFlow(
|
||||
intent: Intent,
|
||||
flags: Int = Context.BIND_AUTO_CREATE,
|
||||
maxRetries: Int = 10,
|
||||
retryDelayMillis: Long = 200L
|
||||
): Flow<Pair<IBinder?, String>> = callbackFlow {
|
||||
val connection = object : ServiceConnection {
|
||||
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
|
||||
if (binder != null) {
|
||||
try {
|
||||
@Suppress("UNCHECKED_CAST") val casted = binder as? T
|
||||
if (casted != null) {
|
||||
trySend(Pair(casted, ""))
|
||||
} else {
|
||||
trySend(Pair(null, "Binder is not of type ${T::class.java}"))
|
||||
}
|
||||
} catch (e: RemoteException) {
|
||||
trySend(Pair(null, "Failed to link to death: ${e.message}"))
|
||||
}
|
||||
} else {
|
||||
trySend(Pair(null, "Binder empty"))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
trySend(Pair(null, "Service disconnected"))
|
||||
}
|
||||
}
|
||||
|
||||
val success = withContext(Dispatchers.Main) {
|
||||
bindService(intent, connection, flags)
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
throw IllegalStateException("bindService() failed, will retry")
|
||||
}
|
||||
|
||||
awaitClose {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
unbindService(connection)
|
||||
trySend(Pair(null, ""))
|
||||
}
|
||||
}
|
||||
}.retryWhen { cause, attempt ->
|
||||
if (attempt < maxRetries && cause is Exception) {
|
||||
delay(retryDelayMillis)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
val Long.formatBytes: String
|
||||
get() {
|
||||
val units = arrayOf("B", "KB", "MB", "GB", "TB")
|
||||
var size = this.toDouble()
|
||||
var unitIndex = 0
|
||||
|
||||
while (size >= 1024 && unitIndex < units.size - 1) {
|
||||
size /= 1024
|
||||
unitIndex++
|
||||
}
|
||||
|
||||
return if (unitIndex == 0) {
|
||||
"${size.toLong()}${units[unitIndex]}"
|
||||
} else {
|
||||
"%.1f${units[unitIndex]}".format(size)
|
||||
}
|
||||
}
|
||||
|
||||
fun String.chunkedForAidl(charset: Charset = Charsets.UTF_8): List<ByteArray> {
|
||||
val allBytes = toByteArray(charset)
|
||||
val total = allBytes.size
|
||||
val maxBytes = when {
|
||||
total <= 100 * 1024 -> total
|
||||
total <= 1024 * 1024 -> 64 * 1024
|
||||
total <= 10 * 1024 * 1024 -> 128 * 1024
|
||||
else -> 256 * 1024
|
||||
}
|
||||
|
||||
val result = mutableListOf<ByteArray>()
|
||||
var index = 0
|
||||
while (index < total) {
|
||||
val end = minOf(index + maxBytes, total)
|
||||
result.add(allBytes.copyOfRange(index, end))
|
||||
index = end
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
fun <T : List<ByteArray>> T.formatString(charset: Charset = Charsets.UTF_8): String {
|
||||
val totalSize = this.sumOf { it.size }
|
||||
val combined = ByteArray(totalSize)
|
||||
var offset = 0
|
||||
forEach { byteArray ->
|
||||
byteArray.copyInto(combined, offset)
|
||||
offset += byteArray.size
|
||||
}
|
||||
return String(combined, charset)
|
||||
}
|
||||
@@ -1,47 +1,47 @@
|
||||
package com.follow.clash.common
|
||||
|
||||
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import com.google.firebase.FirebaseApp
|
||||
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
|
||||
object GlobalState : CoroutineScope by CoroutineScope(Dispatchers.Default) {
|
||||
|
||||
object GlobalState : CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) {
|
||||
const val NOTIFICATION_CHANNEL = "FlClash"
|
||||
|
||||
const val NOTIFICATION_ID = 1
|
||||
|
||||
val packageName: String
|
||||
get() = application.packageName
|
||||
|
||||
val RECEIVE_BROADCASTS_PERMISSIONS: String
|
||||
get() = "${packageName}.permission.RECEIVE_BROADCASTS"
|
||||
|
||||
|
||||
private var _application: Application? = null
|
||||
val receiveBroadcastPermission: String
|
||||
get() = "$packageName.permission.RECEIVE_BROADCASTS"
|
||||
|
||||
val application: Application
|
||||
get() = _application!!
|
||||
get() = checkNotNull(appInstance) { "GlobalState is not initialized" }
|
||||
|
||||
|
||||
fun log(text: String) {
|
||||
Log.d("[FlClash]", text)
|
||||
}
|
||||
@Volatile
|
||||
private var appInstance: Application? = null
|
||||
|
||||
fun init(application: Application) {
|
||||
_application = application
|
||||
appInstance = application
|
||||
}
|
||||
|
||||
fun log(text: String) {
|
||||
Log.d("FlClash", text)
|
||||
}
|
||||
|
||||
fun setCrashlytics(enable: Boolean) {
|
||||
_application?.let {
|
||||
FirebaseApp.initializeApp(it)
|
||||
FirebaseCrashlytics.getInstance().isCrashlyticsCollectionEnabled = enable
|
||||
if (enable) {
|
||||
log("init crashlytics ${it.processName}")
|
||||
}
|
||||
FirebaseApp.initializeApp(application)
|
||||
FirebaseCrashlytics.getInstance().isCrashlyticsCollectionEnabled = enable
|
||||
if (enable) {
|
||||
log("Crashlytics enabled for ${application.processName}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun didCrashOnPreviousExecution(): Boolean {
|
||||
FirebaseApp.initializeApp(application)
|
||||
return FirebaseCrashlytics.getInstance().didCrashOnPreviousExecution()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
package com.follow.clash.common
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
class ServiceDelegate<T>(
|
||||
private val intent: Intent,
|
||||
private val onServiceDisconnected: ((String) -> Unit)? = null,
|
||||
private val interfaceCreator: (IBinder) -> T,
|
||||
) : CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) {
|
||||
|
||||
private val _bindingState = AtomicBoolean(false)
|
||||
|
||||
private var _serviceState = MutableStateFlow<Pair<T?, String>?>(null)
|
||||
|
||||
val serviceState: StateFlow<Pair<T?, String>?> = _serviceState
|
||||
private var job: Job? = null
|
||||
|
||||
private fun handleBind(data: Pair<IBinder?, String>) {
|
||||
data.first?.let {
|
||||
_serviceState.value = Pair(interfaceCreator(it), data.second)
|
||||
} ?: run {
|
||||
_serviceState.value = Pair(null, data.second)
|
||||
unbind()
|
||||
onServiceDisconnected?.invoke(data.second)
|
||||
_bindingState.set(false)
|
||||
}
|
||||
}
|
||||
|
||||
fun bind() {
|
||||
if (_bindingState.compareAndSet(false, true)) {
|
||||
job?.cancel()
|
||||
job = null
|
||||
_serviceState.value = null
|
||||
job = launch {
|
||||
runCatching {
|
||||
GlobalState.application.bindServiceFlow<IBinder>(intent)
|
||||
.collect { handleBind(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend inline fun <R> useService(
|
||||
timeoutMillis: Long = 5000, crossinline block: suspend (T) -> R
|
||||
): Result<R> {
|
||||
return runCatching {
|
||||
withTimeout(timeoutMillis) {
|
||||
val state = serviceState.filterNotNull().first()
|
||||
state.first?.let {
|
||||
withContext(Dispatchers.Default) {
|
||||
block(it)
|
||||
}
|
||||
} ?: throw Exception(state.second)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun unbind() {
|
||||
if (_bindingState.compareAndSet(true, false)) {
|
||||
job?.cancel()
|
||||
job = null
|
||||
_serviceState.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package com.follow.clash.common
|
||||
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
|
||||
|
||||
fun tickerFlow(delayMillis: Long, initialDelayMillis: Long = delayMillis): Flow<Unit> = flow {
|
||||
delay(initialDelayMillis)
|
||||
while (true) {
|
||||
emit(Unit)
|
||||
delay(delayMillis)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="FlClash">FlClash</string>
|
||||
<string name="app_name">FlClash</string>
|
||||
<string name="service_channel_name">FlClash Service</string>
|
||||
</resources>
|
||||
|
||||
@@ -13,13 +13,6 @@ android {
|
||||
minSdk = libs.versions.minSdk.get().toInt()
|
||||
}
|
||||
|
||||
|
||||
sourceSets {
|
||||
getByName("main") {
|
||||
jniLibs.srcDirs("src/main/jniLibs")
|
||||
}
|
||||
}
|
||||
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
path("src/main/cpp/CMakeLists.txt")
|
||||
@@ -32,14 +25,6 @@ android {
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
@@ -48,7 +33,6 @@ kotlin {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
dependencies {
|
||||
implementation(libs.annotation.jvm)
|
||||
}
|
||||
|
||||
@@ -34,9 +34,9 @@ Java_com_follow_clash_core_Core_updateDNS(JNIEnv *env, jobject thiz, jstring dns
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_follow_clash_core_Core_invokeAction(JNIEnv *env, jobject thiz, jstring data, jobject cb) {
|
||||
Java_com_follow_clash_core_Core_invokeMethod(JNIEnv *env, jobject thiz, jstring data, jobject cb) {
|
||||
const auto interface = new_global(cb);
|
||||
invokeAction(interface, get_string(data));
|
||||
invokeMethod(interface, get_string(data));
|
||||
}
|
||||
|
||||
extern "C"
|
||||
@@ -54,14 +54,16 @@ extern "C"
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_com_follow_clash_core_Core_getTraffic(JNIEnv *env, jobject thiz,
|
||||
const jboolean only_statistics_proxy) {
|
||||
return new_string(getTraffic(only_statistics_proxy));
|
||||
scoped_string traffic = getTraffic(only_statistics_proxy);
|
||||
return new_string(traffic);
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_com_follow_clash_core_Core_getTotalTraffic(JNIEnv *env, jobject thiz,
|
||||
const jboolean only_statistics_proxy) {
|
||||
return new_string(getTotalTraffic(only_statistics_proxy));
|
||||
scoped_string traffic = getTotalTraffic(only_statistics_proxy);
|
||||
return new_string(traffic);
|
||||
}
|
||||
|
||||
extern "C"
|
||||
@@ -106,21 +108,31 @@ call_tun_interface_resolve_process_impl(void *tun_interface, const int protocol,
|
||||
const char *target,
|
||||
const int uid) {
|
||||
ATTACH_JNI();
|
||||
const auto packageName = reinterpret_cast<jstring>(env->CallObjectMethod(
|
||||
const auto source_string = new_string(source);
|
||||
const auto target_string = new_string(target);
|
||||
const auto package_name = reinterpret_cast<jstring>(env->CallObjectMethod(
|
||||
static_cast<jobject>(tun_interface),
|
||||
m_tun_interface_resolve_process,
|
||||
protocol,
|
||||
new_string(source),
|
||||
new_string(target),
|
||||
source_string,
|
||||
target_string,
|
||||
uid));
|
||||
return get_string(packageName);
|
||||
env->DeleteLocalRef(source_string);
|
||||
env->DeleteLocalRef(target_string);
|
||||
const auto result = get_string(package_name);
|
||||
if (package_name != nullptr) {
|
||||
env->DeleteLocalRef(package_name);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static void call_invoke_interface_result_impl(void *invoke_interface, const char *data) {
|
||||
ATTACH_JNI();
|
||||
const auto value = new_string(data);
|
||||
env->CallVoidMethod(static_cast<jobject>(invoke_interface),
|
||||
m_invoke_interface_result,
|
||||
new_string(data));
|
||||
value);
|
||||
env->DeleteLocalRef(value);
|
||||
}
|
||||
|
||||
extern "C"
|
||||
@@ -166,7 +178,7 @@ Java_com_follow_clash_core_Core_stopTun(JNIEnv *env, jobject thiz) {
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_follow_clash_core_Core_invokeAction(JNIEnv *env, jobject thiz, jstring data, jobject cb) {
|
||||
Java_com_follow_clash_core_Core_invokeMethod(JNIEnv *env, jobject thiz, jstring data, jobject cb) {
|
||||
}
|
||||
|
||||
extern "C"
|
||||
@@ -188,11 +200,13 @@ extern "C"
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_com_follow_clash_core_Core_getTraffic(JNIEnv *env, jobject thiz,
|
||||
const jboolean only_statistics_proxy) {
|
||||
return env->NewStringUTF("{}");
|
||||
}
|
||||
extern "C"
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_com_follow_clash_core_Core_getTotalTraffic(JNIEnv *env, jobject thiz,
|
||||
const jboolean only_statistics_proxy) {
|
||||
return env->NewStringUTF("{}");
|
||||
}
|
||||
|
||||
extern "C"
|
||||
@@ -205,4 +219,4 @@ JNIEXPORT void JNICALL
|
||||
Java_com_follow_clash_core_Core_quickSetup(JNIEnv *env, jobject thiz, jstring init_params_string,
|
||||
jstring setup_params_string, jobject cb) {
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -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,33 +86,33 @@ data object Core {
|
||||
|
||||
private external fun setEventListener(cb: InvokeInterface?)
|
||||
|
||||
fun callSetEventListener(
|
||||
cb: ((result: String?) -> Unit)?
|
||||
fun updateEventListener(
|
||||
callback: ((result: String?) -> Unit)?,
|
||||
) {
|
||||
when (cb != null) {
|
||||
true -> setEventListener(
|
||||
if (callback == null) {
|
||||
setEventListener(null)
|
||||
} else {
|
||||
setEventListener(
|
||||
object : InvokeInterface {
|
||||
override fun onResult(result: String?) {
|
||||
cb(result)
|
||||
callback(result)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
false -> setEventListener(null)
|
||||
}
|
||||
}
|
||||
|
||||
fun quickSetup(
|
||||
initParamsString: String,
|
||||
setupParamsString: String,
|
||||
cb: (result: String?) -> Unit,
|
||||
callback: (result: String?) -> Unit,
|
||||
) {
|
||||
quickSetup(
|
||||
initParamsString,
|
||||
setupParamsString,
|
||||
object : InvokeInterface {
|
||||
override fun onResult(result: String?) {
|
||||
cb(result)
|
||||
callback(result)
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -121,7 +121,7 @@ data object Core {
|
||||
private external fun quickSetup(
|
||||
initParamsString: String,
|
||||
setupParamsString: String,
|
||||
cb: InvokeInterface
|
||||
cb: InvokeInterface,
|
||||
)
|
||||
|
||||
external fun stopTun()
|
||||
@@ -133,4 +133,4 @@ data object Core {
|
||||
init {
|
||||
System.loadLibrary("core")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,5 +5,6 @@ import androidx.annotation.Keep
|
||||
@Keep
|
||||
interface TunInterface {
|
||||
fun protect(fd: Int)
|
||||
|
||||
fun resolverProcess(protocol: Int, source: String, target: String, uid: Int): String
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
org.gradle.jvmargs=-Xmx4G
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
# This builtInKotlin flag was added automatically by Flutter migrator
|
||||
android.builtInKotlin=false
|
||||
# This newDsl flag was added automatically by Flutter migrator
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
[versions]
|
||||
#agp = "8.10.1"
|
||||
firebaseBom = "34.15.0"
|
||||
minSdk = "23"
|
||||
targetSdk = "36"
|
||||
@@ -9,10 +8,7 @@ coreKtx = "1.17.0"
|
||||
annotationJvm = "1.9.1"
|
||||
coreSplashscreen = "1.0.1"
|
||||
gson = "2.13.1"
|
||||
kotlin = "2.2.20"
|
||||
smaliDexlib2 = "3.0.9"
|
||||
firebaseCrashlyticsKtx = "20.0.6"
|
||||
firebaseCommonKtx = "22.1.0"
|
||||
|
||||
[libraries]
|
||||
androidx-core = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
|
||||
@@ -23,5 +19,3 @@ firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "fir
|
||||
firebase-crashlytics-ndk = { module = "com.google.firebase:firebase-crashlytics-ndk" }
|
||||
gson = { module = "com.google.code.gson:gson", version.ref = "gson" }
|
||||
smali-dexlib2 = { module = "com.android.tools.smali:smali-dexlib2", version.ref = "smaliDexlib2" }
|
||||
firebase-crashlytics-ktx = { group = "com.google.firebase", name = "firebase-crashlytics-ktx", version.ref = "firebaseCrashlyticsKtx" }
|
||||
firebase-common-ktx = { group = "com.google.firebase", name = "firebase-common-ktx", version.ref = "firebaseCommonKtx" }
|
||||
|
||||
@@ -6,29 +6,17 @@ plugins {
|
||||
|
||||
android {
|
||||
namespace = "com.follow.clash.service"
|
||||
compileSdk = 36
|
||||
compileSdk = libs.versions.compileSdk.get().toInt()
|
||||
|
||||
defaultConfig {
|
||||
minSdk = libs.versions.minSdk.get().toInt()
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
aidl = true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
@@ -37,7 +25,6 @@ kotlin {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core"))
|
||||
implementation(project(":common"))
|
||||
|
||||
@@ -8,8 +8,7 @@
|
||||
android:name=".VpnService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse"
|
||||
android:permission="android.permission.BIND_VPN_SERVICE"
|
||||
android:process=":remote">
|
||||
android:permission="android.permission.BIND_VPN_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.net.VpnService" />
|
||||
</intent-filter>
|
||||
@@ -19,31 +18,23 @@
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".CommonService"
|
||||
android:name=".ProxyService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse"
|
||||
android:process=":remote">
|
||||
android:foregroundServiceType="specialUse">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="proxy" />
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".RemoteService"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:process=":remote" />
|
||||
|
||||
<provider
|
||||
android:name=".FilesProvider"
|
||||
android:authorities="${applicationId}.files"
|
||||
android:exported="true"
|
||||
android:grantUriPermissions="true"
|
||||
android:permission="android.permission.MANAGE_DOCUMENTS"
|
||||
android:process=":remote">
|
||||
android:permission="android.permission.MANAGE_DOCUMENTS">
|
||||
<intent-filter>
|
||||
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
|
||||
</intent-filter>
|
||||
</provider>
|
||||
</application>
|
||||
</manifest>
|
||||
</manifest>
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
// IAckInterface.aidl
|
||||
package com.follow.clash.service;
|
||||
|
||||
import com.follow.clash.service.IAckInterface;
|
||||
|
||||
interface IAckInterface {
|
||||
oneway void onAck();
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
// ICallbackInterface.aidl
|
||||
package com.follow.clash.service;
|
||||
|
||||
import com.follow.clash.service.IAckInterface;
|
||||
|
||||
interface ICallbackInterface {
|
||||
oneway void onResult(in byte[] data,in boolean isSuccess, in IAckInterface ack);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
// IEventInterface.aidl
|
||||
package com.follow.clash.service;
|
||||
|
||||
import com.follow.clash.service.IAckInterface;
|
||||
|
||||
interface IEventInterface {
|
||||
oneway void onEvent(in String id, in byte[] data,in boolean isSuccess, in IAckInterface ack);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
// IRemoteInterface.aidl
|
||||
package com.follow.clash.service;
|
||||
|
||||
import com.follow.clash.service.ICallbackInterface;
|
||||
import com.follow.clash.service.IEventInterface;
|
||||
import com.follow.clash.service.IResultInterface;
|
||||
import com.follow.clash.service.IVoidInterface;
|
||||
import com.follow.clash.service.models.VpnOptions;
|
||||
import com.follow.clash.service.models.NotificationParams;
|
||||
|
||||
interface IRemoteInterface {
|
||||
void invokeAction(in String data, in ICallbackInterface callback);
|
||||
void quickSetup(in String initParamsString, in String setupParamsString, in ICallbackInterface callback, in IVoidInterface onStarted);
|
||||
void updateNotificationParams(in NotificationParams params);
|
||||
void startService(in VpnOptions options, in long runTime, in IResultInterface result);
|
||||
void stopService(in IResultInterface result);
|
||||
void setEventListener(in IEventInterface event);
|
||||
void setCrashlytics(in boolean enable);
|
||||
long getRunTime();
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
// IResultInterface.aidl
|
||||
package com.follow.clash.service;
|
||||
|
||||
interface IResultInterface {
|
||||
oneway void onResult(in long runTime);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
// IVoidInterface.aidl
|
||||
package com.follow.clash.service;
|
||||
|
||||
interface IVoidInterface {
|
||||
oneway void invoke();
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
//AccessControl.aidl
|
||||
package com.follow.clash.service.models;
|
||||
|
||||
parcelable AccessControl;
|
||||
@@ -1,4 +0,0 @@
|
||||
//NotificationParams.aidl
|
||||
package com.follow.clash.service.models;
|
||||
|
||||
parcelable NotificationParams;
|
||||
@@ -1,6 +0,0 @@
|
||||
//VpnOptions.aidl
|
||||
package com.follow.clash.service.models;
|
||||
|
||||
import com.follow.clash.service.models.AccessControl;
|
||||
|
||||
parcelable VpnOptions;
|
||||
@@ -1,64 +0,0 @@
|
||||
package com.follow.clash.service
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.Binder
|
||||
import android.os.IBinder
|
||||
import com.follow.clash.core.Core
|
||||
import com.follow.clash.service.modules.NetworkObserveModule
|
||||
import com.follow.clash.service.modules.NotificationModule
|
||||
import com.follow.clash.service.modules.SuspendModule
|
||||
import com.follow.clash.service.modules.moduleLoader
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
||||
class CommonService : Service(), IBaseService,
|
||||
CoroutineScope by CoroutineScope(Dispatchers.Default) {
|
||||
|
||||
private val self: CommonService
|
||||
get() = this
|
||||
|
||||
private val loader = moduleLoader {
|
||||
install(NetworkObserveModule(self))
|
||||
install(NotificationModule(self))
|
||||
install(SuspendModule(self))
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
handleCreate()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
handleDestroy()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onLowMemory() {
|
||||
Core.forceGC()
|
||||
super.onLowMemory()
|
||||
}
|
||||
|
||||
private val binder = LocalBinder()
|
||||
|
||||
inner class LocalBinder : Binder() {
|
||||
fun getService(): CommonService = this@CommonService
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent): IBinder {
|
||||
return binder
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
try {
|
||||
loader.load()
|
||||
} catch (_: Exception) {
|
||||
stop()
|
||||
}
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
loader.cancel()
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
@@ -6,60 +6,34 @@ import android.os.CancellationSignal
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.provider.DocumentsContract
|
||||
import android.provider.DocumentsProvider
|
||||
import com.follow.clash.common.R as CommonR
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.IOException
|
||||
|
||||
class FilesProvider : DocumentsProvider() {
|
||||
override fun onCreate() = true
|
||||
|
||||
companion object {
|
||||
private const val DEFAULT_ROOT_ID = "0"
|
||||
|
||||
private val DEFAULT_DOCUMENT_COLUMNS = arrayOf(
|
||||
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
|
||||
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
|
||||
DocumentsContract.Document.COLUMN_MIME_TYPE,
|
||||
DocumentsContract.Document.COLUMN_FLAGS,
|
||||
DocumentsContract.Document.COLUMN_SIZE,
|
||||
)
|
||||
private val DEFAULT_ROOT_COLUMNS = arrayOf(
|
||||
DocumentsContract.Root.COLUMN_ROOT_ID,
|
||||
DocumentsContract.Root.COLUMN_FLAGS,
|
||||
DocumentsContract.Root.COLUMN_ICON,
|
||||
DocumentsContract.Root.COLUMN_TITLE,
|
||||
DocumentsContract.Root.COLUMN_SUMMARY,
|
||||
DocumentsContract.Root.COLUMN_DOCUMENT_ID
|
||||
)
|
||||
}
|
||||
|
||||
override fun onCreate(): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
override fun queryRoots(projection: Array<String>?): Cursor {
|
||||
return MatrixCursor(projection ?: DEFAULT_ROOT_COLUMNS).apply {
|
||||
newRow().apply {
|
||||
add(DocumentsContract.Root.COLUMN_ROOT_ID, DEFAULT_ROOT_ID)
|
||||
add(DocumentsContract.Root.COLUMN_FLAGS, DocumentsContract.Root.FLAG_LOCAL_ONLY)
|
||||
add(DocumentsContract.Root.COLUMN_ICON, R.drawable.ic_service)
|
||||
add(DocumentsContract.Root.COLUMN_TITLE, "FlClash")
|
||||
add(DocumentsContract.Root.COLUMN_SUMMARY, "Data")
|
||||
add(DocumentsContract.Root.COLUMN_DOCUMENT_ID, "/")
|
||||
}
|
||||
override fun queryRoots(projection: Array<String>?): Cursor =
|
||||
MatrixCursor(projection ?: DEFAULT_ROOT_COLUMNS).apply {
|
||||
newRow()
|
||||
.add(DocumentsContract.Root.COLUMN_ROOT_ID, DEFAULT_ROOT_ID)
|
||||
.add(DocumentsContract.Root.COLUMN_FLAGS, DocumentsContract.Root.FLAG_LOCAL_ONLY)
|
||||
.add(DocumentsContract.Root.COLUMN_ICON, R.drawable.ic_service)
|
||||
.add(
|
||||
DocumentsContract.Root.COLUMN_TITLE,
|
||||
context?.getString(CommonR.string.app_name).orEmpty(),
|
||||
)
|
||||
.add(DocumentsContract.Root.COLUMN_DOCUMENT_ID, ROOT_DOCUMENT_ID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun queryChildDocuments(
|
||||
parentDocumentId: String,
|
||||
projection: Array<String>?,
|
||||
sortOrder: String?
|
||||
sortOrder: String?,
|
||||
): Cursor {
|
||||
val result = MatrixCursor(resolveDocumentProjection(projection))
|
||||
val parentFile = if (parentDocumentId == "/") {
|
||||
context?.filesDir
|
||||
} else {
|
||||
File(parentDocumentId)
|
||||
} ?: throw FileNotFoundException("Parent directory not found")
|
||||
val result = MatrixCursor(projection ?: DEFAULT_DOCUMENT_COLUMNS)
|
||||
val parentFile = resolveFile(parentDocumentId)
|
||||
parentFile.listFiles()?.forEach { file ->
|
||||
includeFile(result, file)
|
||||
}
|
||||
@@ -67,20 +41,18 @@ class FilesProvider : DocumentsProvider() {
|
||||
}
|
||||
|
||||
override fun queryDocument(documentId: String, projection: Array<String>?): Cursor {
|
||||
val result = MatrixCursor(resolveDocumentProjection(projection))
|
||||
val file = File(documentId)
|
||||
includeFile(result, file)
|
||||
val result = MatrixCursor(projection ?: DEFAULT_DOCUMENT_COLUMNS)
|
||||
includeFile(result, resolveFile(documentId))
|
||||
return result
|
||||
}
|
||||
|
||||
override fun openDocument(
|
||||
documentId: String,
|
||||
mode: String,
|
||||
signal: CancellationSignal?
|
||||
signal: CancellationSignal?,
|
||||
): ParcelFileDescriptor {
|
||||
val file = File(documentId)
|
||||
val accessMode = ParcelFileDescriptor.parseMode(mode)
|
||||
return ParcelFileDescriptor.open(file, accessMode)
|
||||
return ParcelFileDescriptor.open(resolveFile(documentId), accessMode)
|
||||
}
|
||||
|
||||
private fun includeFile(result: MatrixCursor, file: File) {
|
||||
@@ -88,23 +60,54 @@ class FilesProvider : DocumentsProvider() {
|
||||
add(DocumentsContract.Document.COLUMN_DOCUMENT_ID, file.absolutePath)
|
||||
add(DocumentsContract.Document.COLUMN_DISPLAY_NAME, file.name)
|
||||
add(DocumentsContract.Document.COLUMN_SIZE, file.length())
|
||||
add(
|
||||
DocumentsContract.Document.COLUMN_FLAGS,
|
||||
DocumentsContract.Document.FLAG_SUPPORTS_WRITE or DocumentsContract.Document.FLAG_SUPPORTS_DELETE
|
||||
)
|
||||
add(DocumentsContract.Document.COLUMN_MIME_TYPE, getDocumentType(file))
|
||||
val flags = if (file.isFile) {
|
||||
DocumentsContract.Document.FLAG_SUPPORTS_WRITE
|
||||
} else {
|
||||
0
|
||||
}
|
||||
val mimeType = if (file.isDirectory) {
|
||||
DocumentsContract.Document.MIME_TYPE_DIR
|
||||
} else {
|
||||
"application/octet-stream"
|
||||
}
|
||||
add(DocumentsContract.Document.COLUMN_FLAGS, flags)
|
||||
add(DocumentsContract.Document.COLUMN_MIME_TYPE, mimeType)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDocumentType(file: File): String {
|
||||
return if (file.isDirectory) {
|
||||
DocumentsContract.Document.MIME_TYPE_DIR
|
||||
} else {
|
||||
"application/octet-stream"
|
||||
private fun resolveFile(documentId: String): File {
|
||||
val root = context?.filesDir?.canonicalFile
|
||||
?: throw FileNotFoundException("App files directory is unavailable")
|
||||
val file = try {
|
||||
if (documentId == ROOT_DOCUMENT_ID) root else File(documentId).canonicalFile
|
||||
} catch (error: IOException) {
|
||||
throw FileNotFoundException(error.message).apply { initCause(error) }
|
||||
}
|
||||
val isInsideRoot = file == root || file.path.startsWith("${root.path}${File.separator}")
|
||||
if (!isInsideRoot) {
|
||||
throw FileNotFoundException("Document is outside the app files directory")
|
||||
}
|
||||
return file
|
||||
}
|
||||
|
||||
private fun resolveDocumentProjection(projection: Array<String>?): Array<String> {
|
||||
return projection ?: DEFAULT_DOCUMENT_COLUMNS
|
||||
private companion object {
|
||||
const val DEFAULT_ROOT_ID = "0"
|
||||
const val ROOT_DOCUMENT_ID = "/"
|
||||
|
||||
val DEFAULT_DOCUMENT_COLUMNS = arrayOf(
|
||||
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
|
||||
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
|
||||
DocumentsContract.Document.COLUMN_MIME_TYPE,
|
||||
DocumentsContract.Document.COLUMN_FLAGS,
|
||||
DocumentsContract.Document.COLUMN_SIZE,
|
||||
)
|
||||
val DEFAULT_ROOT_COLUMNS = arrayOf(
|
||||
DocumentsContract.Root.COLUMN_ROOT_ID,
|
||||
DocumentsContract.Root.COLUMN_FLAGS,
|
||||
DocumentsContract.Root.COLUMN_ICON,
|
||||
DocumentsContract.Root.COLUMN_TITLE,
|
||||
DocumentsContract.Root.COLUMN_SUMMARY,
|
||||
DocumentsContract.Root.COLUMN_DOCUMENT_ID,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
package com.follow.clash.service
|
||||
|
||||
import com.follow.clash.common.BroadcastAction
|
||||
import com.follow.clash.common.GlobalState
|
||||
import com.follow.clash.common.sendBroadcast
|
||||
|
||||
interface IBaseService {
|
||||
fun handleCreate() {
|
||||
GlobalState.log("Service create")
|
||||
BroadcastAction.SERVICE_CREATED.sendBroadcast()
|
||||
}
|
||||
|
||||
fun handleDestroy() {
|
||||
GlobalState.log("Service destroy")
|
||||
BroadcastAction.SERVICE_DESTROYED.sendBroadcast()
|
||||
}
|
||||
|
||||
fun start()
|
||||
|
||||
fun stop()
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.follow.clash.service
|
||||
|
||||
import android.app.Service
|
||||
import com.follow.clash.common.BroadcastAction
|
||||
import com.follow.clash.common.GlobalState
|
||||
import com.follow.clash.common.sendBroadcast
|
||||
|
||||
interface ManagedService {
|
||||
fun start()
|
||||
|
||||
fun stop()
|
||||
}
|
||||
|
||||
internal fun Service.notifyCreated() {
|
||||
GlobalState.log("Service created")
|
||||
BroadcastAction.SERVICE_CREATED.sendBroadcast()
|
||||
}
|
||||
|
||||
internal fun Service.notifyDestroyed() {
|
||||
GlobalState.log("Service destroyed")
|
||||
BroadcastAction.SERVICE_DESTROYED.sendBroadcast()
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.follow.clash.service
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.Binder
|
||||
import android.os.IBinder
|
||||
import com.follow.clash.core.Core
|
||||
import com.follow.clash.service.modules.ServiceModules
|
||||
|
||||
class ProxyService : Service(), ManagedService {
|
||||
private val modules = ServiceModules(this)
|
||||
private val binder = LocalBinder()
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
notifyCreated()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
modules.stop()
|
||||
notifyDestroyed()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onLowMemory() {
|
||||
Core.forceGC()
|
||||
super.onLowMemory()
|
||||
}
|
||||
|
||||
inner class LocalBinder : Binder() {
|
||||
val service: ProxyService
|
||||
get() = this@ProxyService
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent): IBinder = binder
|
||||
|
||||
override fun start() {
|
||||
try {
|
||||
modules.start()
|
||||
} catch (error: Exception) {
|
||||
stop()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
modules.stop()
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
package com.follow.clash.service
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import com.follow.clash.common.GlobalState
|
||||
import com.follow.clash.common.ServiceDelegate
|
||||
import com.follow.clash.common.chunkedForAidl
|
||||
import com.follow.clash.common.intent
|
||||
import com.follow.clash.core.Core
|
||||
import com.follow.clash.service.State.delegate
|
||||
import com.follow.clash.service.State.intent
|
||||
import com.follow.clash.service.State.runLock
|
||||
import com.follow.clash.service.models.NotificationParams
|
||||
import com.follow.clash.service.models.VpnOptions
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.util.UUID
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
class RemoteService : Service(),
|
||||
CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) {
|
||||
private fun handleStopService(result: IResultInterface) {
|
||||
launch {
|
||||
runLock.withLock {
|
||||
delegate?.useService { service ->
|
||||
service.stop()
|
||||
delegate?.unbind()
|
||||
}
|
||||
State.runTime = 0
|
||||
result.onResult(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleServiceDisconnected(message: String) {
|
||||
GlobalState.log("Background service disconnected: $message")
|
||||
intent = null
|
||||
delegate = null
|
||||
}
|
||||
|
||||
private fun handleStartService(runTime: Long, result: IResultInterface) {
|
||||
launch {
|
||||
runLock.withLock {
|
||||
val nextIntent = when (State.options?.enable == true) {
|
||||
true -> VpnService::class.intent
|
||||
false -> CommonService::class.intent
|
||||
}
|
||||
if (intent != nextIntent) {
|
||||
delegate?.unbind()
|
||||
delegate = ServiceDelegate(nextIntent, ::handleServiceDisconnected) { binder ->
|
||||
when (binder) {
|
||||
is VpnService.LocalBinder -> binder.getService()
|
||||
is CommonService.LocalBinder -> binder.getService()
|
||||
else -> throw IllegalArgumentException("Invalid binder type")
|
||||
}
|
||||
}
|
||||
intent = nextIntent
|
||||
delegate?.bind()
|
||||
}
|
||||
delegate?.useService { service ->
|
||||
service.start()
|
||||
}
|
||||
State.runTime = when (runTime != 0L) {
|
||||
true -> runTime
|
||||
false -> System.currentTimeMillis()
|
||||
}
|
||||
result.onResult(State.runTime)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val binder = object : IRemoteInterface.Stub() {
|
||||
override fun invokeAction(data: String, callback: ICallbackInterface) {
|
||||
Core.invokeAction(data) {
|
||||
launch {
|
||||
runCatching {
|
||||
val chunks = it?.chunkedForAidl() ?: listOf()
|
||||
for ((index, chunk) in chunks.withIndex()) {
|
||||
suspendCancellableCoroutine { cont ->
|
||||
callback.onResult(
|
||||
chunk,
|
||||
index == chunks.lastIndex,
|
||||
object : IAckInterface.Stub() {
|
||||
override fun onAck() {
|
||||
cont.resume(Unit)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun quickSetup(
|
||||
initParamsString: String,
|
||||
setupParamsString: String,
|
||||
callback: ICallbackInterface,
|
||||
onStarted: IVoidInterface
|
||||
) {
|
||||
Core.quickSetup(initParamsString, setupParamsString) {
|
||||
launch {
|
||||
runCatching {
|
||||
val chunks = it?.chunkedForAidl() ?: listOf()
|
||||
for ((index, chunk) in chunks.withIndex()) {
|
||||
suspendCancellableCoroutine { cont ->
|
||||
callback.onResult(
|
||||
chunk,
|
||||
index == chunks.lastIndex,
|
||||
object : IAckInterface.Stub() {
|
||||
override fun onAck() {
|
||||
cont.resume(Unit)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
onStarted()
|
||||
}
|
||||
|
||||
override fun updateNotificationParams(params: NotificationParams?) {
|
||||
State.notificationParamsFlow.tryEmit(params)
|
||||
}
|
||||
|
||||
|
||||
override fun startService(
|
||||
options: VpnOptions,
|
||||
runtime: Long,
|
||||
result: IResultInterface,
|
||||
) {
|
||||
GlobalState.log("remote startService")
|
||||
State.options = options
|
||||
handleStartService(runtime, result)
|
||||
}
|
||||
|
||||
override fun stopService(result: IResultInterface) {
|
||||
handleStopService(result)
|
||||
}
|
||||
|
||||
override fun setEventListener(eventListener: IEventInterface?) {
|
||||
GlobalState.log("RemoveEventListener ${eventListener == null}")
|
||||
when (eventListener != null) {
|
||||
true -> Core.callSetEventListener {
|
||||
launch {
|
||||
runCatching {
|
||||
val id = UUID.randomUUID().toString()
|
||||
val chunks = it?.chunkedForAidl() ?: listOf()
|
||||
for ((index, chunk) in chunks.withIndex()) {
|
||||
suspendCancellableCoroutine { cont ->
|
||||
eventListener.onEvent(
|
||||
id,
|
||||
chunk,
|
||||
index == chunks.lastIndex,
|
||||
object : IAckInterface.Stub() {
|
||||
override fun onAck() {
|
||||
cont.resume(Unit)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false -> Core.callSetEventListener(null)
|
||||
}
|
||||
}
|
||||
|
||||
override fun setCrashlytics(enable: Boolean) {
|
||||
GlobalState.setCrashlytics(enable)
|
||||
}
|
||||
|
||||
override fun getRunTime(): Long {
|
||||
return State.runTime
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder {
|
||||
return binder
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
GlobalState.log("Remote service destroy")
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.follow.clash.service
|
||||
|
||||
import com.follow.clash.service.models.NotificationParams
|
||||
import com.follow.clash.service.models.VpnOptions
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
object ServiceConfig {
|
||||
@Volatile
|
||||
private var currentVpnOptions: VpnOptions? = null
|
||||
private val mutableNotificationParams = MutableStateFlow(NotificationParams())
|
||||
|
||||
val vpnOptions: VpnOptions?
|
||||
get() = currentVpnOptions
|
||||
|
||||
val notificationParams = mutableNotificationParams.asStateFlow()
|
||||
|
||||
fun updateVpnOptions(options: VpnOptions) {
|
||||
currentVpnOptions = options
|
||||
}
|
||||
|
||||
fun updateNotificationParams(params: NotificationParams) {
|
||||
mutableNotificationParams.value = params
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package com.follow.clash.service
|
||||
|
||||
import android.content.Intent
|
||||
import com.follow.clash.common.ServiceDelegate
|
||||
import com.follow.clash.service.models.NotificationParams
|
||||
import com.follow.clash.service.models.VpnOptions
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
|
||||
object State {
|
||||
var options: VpnOptions? = null
|
||||
var notificationParamsFlow: MutableStateFlow<NotificationParams?> = MutableStateFlow(
|
||||
NotificationParams()
|
||||
)
|
||||
|
||||
val runLock = Mutex()
|
||||
var runTime: Long = 0L
|
||||
|
||||
var delegate: ServiceDelegate<IBaseService>? = null
|
||||
|
||||
var intent: Intent? = null
|
||||
}
|
||||
@@ -6,52 +6,41 @@ import android.net.ProxyInfo
|
||||
import android.os.Binder
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.Parcel
|
||||
import android.os.RemoteException
|
||||
import android.util.Log
|
||||
import androidx.core.content.getSystemService
|
||||
import com.follow.clash.common.AccessControlMode
|
||||
import com.follow.clash.common.GlobalState
|
||||
import com.follow.clash.common.R as CommonR
|
||||
import com.follow.clash.core.Core
|
||||
import com.follow.clash.service.models.CIDR
|
||||
import com.follow.clash.service.models.VpnOptions
|
||||
import com.follow.clash.service.models.getIpv4RouteAddress
|
||||
import com.follow.clash.service.models.getIpv6RouteAddress
|
||||
import com.follow.clash.service.models.toCIDR
|
||||
import com.follow.clash.service.modules.NetworkObserveModule
|
||||
import com.follow.clash.service.modules.NotificationModule
|
||||
import com.follow.clash.service.modules.SuspendModule
|
||||
import com.follow.clash.service.modules.moduleLoader
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import com.follow.clash.service.modules.ServiceModules
|
||||
import java.net.InetSocketAddress
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import android.net.VpnService as SystemVpnService
|
||||
|
||||
class VpnService : SystemVpnService(), IBaseService,
|
||||
CoroutineScope by CoroutineScope(Dispatchers.Default) {
|
||||
|
||||
private val self: VpnService
|
||||
get() = this
|
||||
|
||||
private val loader = moduleLoader {
|
||||
install(NetworkObserveModule(self))
|
||||
install(NotificationModule(self))
|
||||
install(SuspendModule(self))
|
||||
}
|
||||
class VpnService : SystemVpnService(), ManagedService {
|
||||
private val modules = ServiceModules(this)
|
||||
private val binder = LocalBinder()
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
handleCreate()
|
||||
notifyCreated()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
handleDestroy()
|
||||
modules.stop()
|
||||
notifyDestroyed()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private val connectivity by lazy {
|
||||
getSystemService<ConnectivityManager>()
|
||||
}
|
||||
private val uidPageNameMap = mutableMapOf<Int, String>()
|
||||
private val uidPackageNameMap = ConcurrentHashMap<Int, String>()
|
||||
|
||||
private fun resolverProcess(
|
||||
protocol: Int,
|
||||
@@ -67,13 +56,15 @@ class VpnService : SystemVpnService(), IBaseService,
|
||||
if (nextUid == -1) {
|
||||
return ""
|
||||
}
|
||||
if (!uidPageNameMap.containsKey(nextUid)) {
|
||||
uidPageNameMap[nextUid] = this.packageManager?.getPackagesForUid(nextUid)?.first() ?: ""
|
||||
return uidPackageNameMap.getOrPut(nextUid) {
|
||||
packageManager
|
||||
.getPackagesForUid(nextUid)
|
||||
?.firstOrNull()
|
||||
.orEmpty()
|
||||
}
|
||||
return uidPageNameMap[nextUid] ?: ""
|
||||
}
|
||||
|
||||
val VpnOptions.address
|
||||
private val VpnOptions.tunAddress
|
||||
get(): String = buildString {
|
||||
append(IPV4_ADDRESS)
|
||||
if (ipv6) {
|
||||
@@ -82,7 +73,7 @@ class VpnService : SystemVpnService(), IBaseService,
|
||||
}
|
||||
}
|
||||
|
||||
val VpnOptions.dns
|
||||
private val VpnOptions.tunDns
|
||||
get(): String {
|
||||
if (dnsHijacking) {
|
||||
return NET_ANY
|
||||
@@ -96,156 +87,142 @@ class VpnService : SystemVpnService(), IBaseService,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun onLowMemory() {
|
||||
Core.forceGC()
|
||||
super.onLowMemory()
|
||||
}
|
||||
|
||||
private val binder = LocalBinder()
|
||||
|
||||
inner class LocalBinder : Binder() {
|
||||
fun getService(): VpnService = this@VpnService
|
||||
|
||||
override fun onTransact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean {
|
||||
try {
|
||||
val isSuccess = super.onTransact(code, data, reply, flags)
|
||||
if (!isSuccess) {
|
||||
GlobalState.log("VpnService disconnected")
|
||||
handleDestroy()
|
||||
}
|
||||
return isSuccess
|
||||
} catch (e: RemoteException) {
|
||||
GlobalState.log("VpnService onTransact $e")
|
||||
return false
|
||||
}
|
||||
}
|
||||
val service: VpnService
|
||||
get() = this@VpnService
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent): IBinder {
|
||||
return binder
|
||||
override fun onBind(intent: Intent): IBinder? =
|
||||
if (intent.action == SystemVpnService.SERVICE_INTERFACE) {
|
||||
super.onBind(intent)
|
||||
} else {
|
||||
binder
|
||||
}
|
||||
|
||||
override fun onRevoke() {
|
||||
stop()
|
||||
notifyDestroyed()
|
||||
}
|
||||
|
||||
private fun handleStart(options: VpnOptions) {
|
||||
val fd = with(Builder()) {
|
||||
val cidr = IPV4_ADDRESS.toCIDR()
|
||||
addAddress(cidr.address, cidr.prefixLength)
|
||||
Log.d(
|
||||
"addAddress", "address: ${cidr.address} prefixLength:${cidr.prefixLength}"
|
||||
)
|
||||
val routeAddress = options.getIpv4RouteAddress()
|
||||
if (routeAddress.isNotEmpty()) {
|
||||
try {
|
||||
routeAddress.forEach { i ->
|
||||
Log.d(
|
||||
"addRoute4", "address: ${i.address} prefixLength:${i.prefixLength}"
|
||||
)
|
||||
addRoute(i.address, i.prefixLength)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
addRoute(NET_ANY, 0)
|
||||
}
|
||||
} else {
|
||||
addRoute(NET_ANY, 0)
|
||||
}
|
||||
if (options.ipv6) {
|
||||
try {
|
||||
val cidr = IPV6_ADDRESS.toCIDR()
|
||||
Log.d(
|
||||
"addAddress6", "address: ${cidr.address} prefixLength:${cidr.prefixLength}"
|
||||
)
|
||||
addAddress(cidr.address, cidr.prefixLength)
|
||||
} catch (_: Exception) {
|
||||
Log.d(
|
||||
"addAddress6", "IPv6 is not supported."
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
val routeAddress = options.getIpv6RouteAddress()
|
||||
if (routeAddress.isNotEmpty()) {
|
||||
try {
|
||||
routeAddress.forEach { i ->
|
||||
Log.d(
|
||||
"addRoute6",
|
||||
"address: ${i.address} prefixLength:${i.prefixLength}"
|
||||
)
|
||||
addRoute(i.address, i.prefixLength)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
addRoute("::", 0)
|
||||
}
|
||||
} else {
|
||||
addRoute(NET_ANY6, 0)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
addRoute(NET_ANY6, 0)
|
||||
}
|
||||
}
|
||||
addDnsServer(DNS)
|
||||
if (options.ipv6) {
|
||||
addDnsServer(DNS6)
|
||||
}
|
||||
setMtu(9000)
|
||||
options.accessControlProps.let { accessControl ->
|
||||
if (accessControl.enable) {
|
||||
when (accessControl.mode) {
|
||||
AccessControlMode.ACCEPT_SELECTED -> {
|
||||
(accessControl.acceptList + packageName).forEach {
|
||||
addAllowedApplication(it)
|
||||
}
|
||||
}
|
||||
|
||||
AccessControlMode.REJECT_SELECTED -> {
|
||||
(accessControl.rejectList - packageName).forEach {
|
||||
addDisallowedApplication(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
setSession("FlClash")
|
||||
addAddressAndRoutes(options)
|
||||
addDnsServers(options)
|
||||
setMtu(MTU)
|
||||
configureAccessControl(options)
|
||||
setSession(getString(CommonR.string.app_name))
|
||||
setBlocking(false)
|
||||
if (Build.VERSION.SDK_INT >= 29) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
setMetered(false)
|
||||
}
|
||||
if (options.allowBypass) {
|
||||
allowBypass()
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && options.systemProxy) {
|
||||
GlobalState.log("Open http proxy")
|
||||
GlobalState.log("Enable VPN HTTP proxy")
|
||||
setHttpProxy(
|
||||
ProxyInfo.buildDirectProxy(
|
||||
"127.0.0.1", options.port, options.bypassDomain
|
||||
)
|
||||
LOCAL_HOST,
|
||||
options.port,
|
||||
options.bypassDomain,
|
||||
),
|
||||
)
|
||||
}
|
||||
establish()?.detachFd()
|
||||
?: throw NullPointerException("Establish VPN rejected by system")
|
||||
?: error("VPN establishment was rejected by the system")
|
||||
}
|
||||
Core.startTun(
|
||||
fd,
|
||||
fd = fd,
|
||||
protect = this::protect,
|
||||
resolverProcess = this::resolverProcess,
|
||||
options.stack,
|
||||
options.address,
|
||||
options.dns
|
||||
stack = options.stack,
|
||||
address = options.tunAddress,
|
||||
dns = options.tunDns,
|
||||
)
|
||||
}
|
||||
|
||||
private fun Builder.addAddressAndRoutes(options: VpnOptions) {
|
||||
val ipv4Address = IPV4_ADDRESS.toCIDR()
|
||||
addAddress(ipv4Address.address, ipv4Address.prefixLength)
|
||||
addRoutes(
|
||||
routes = options::getIpv4RouteAddress,
|
||||
fallbackAddress = NET_ANY,
|
||||
logTag = "addRoute4",
|
||||
)
|
||||
|
||||
if (options.ipv6) {
|
||||
try {
|
||||
val ipv6Address = IPV6_ADDRESS.toCIDR()
|
||||
addAddress(ipv6Address.address, ipv6Address.prefixLength)
|
||||
} catch (_: Exception) {
|
||||
GlobalState.log("IPv6 VPN address is not supported")
|
||||
}
|
||||
addRoutes(
|
||||
routes = options::getIpv6RouteAddress,
|
||||
fallbackAddress = NET_ANY6,
|
||||
logTag = "addRoute6",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Builder.addRoutes(
|
||||
routes: () -> List<CIDR>,
|
||||
fallbackAddress: String,
|
||||
logTag: String,
|
||||
) {
|
||||
val routeList = runCatching(routes).getOrDefault(emptyList())
|
||||
if (routeList.isEmpty()) {
|
||||
addRoute(fallbackAddress, 0)
|
||||
return
|
||||
}
|
||||
try {
|
||||
routeList.forEach { route ->
|
||||
Log.d(logTag, "address: ${route.address} prefixLength: ${route.prefixLength}")
|
||||
addRoute(route.address, route.prefixLength)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
addRoute(fallbackAddress, 0)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Builder.addDnsServers(options: VpnOptions) {
|
||||
addDnsServer(DNS)
|
||||
if (options.ipv6) {
|
||||
addDnsServer(DNS6)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Builder.configureAccessControl(options: VpnOptions) {
|
||||
val accessControl = options.accessControlProps
|
||||
if (!accessControl.enable) return
|
||||
when (accessControl.mode) {
|
||||
AccessControlMode.ACCEPT_SELECTED -> {
|
||||
(accessControl.acceptList + packageName).forEach(::addAllowedApplication)
|
||||
}
|
||||
|
||||
AccessControlMode.REJECT_SELECTED -> {
|
||||
(accessControl.rejectList - packageName).forEach(::addDisallowedApplication)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun start() {
|
||||
try {
|
||||
loader.load()
|
||||
State.options?.let {
|
||||
handleStart(it)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
modules.start()
|
||||
handleStart(requireNotNull(ServiceConfig.vpnOptions) { "VPN options are missing" })
|
||||
} catch (error: Exception) {
|
||||
stop()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
loader.cancel()
|
||||
modules.stop()
|
||||
Core.stopTun()
|
||||
stopSelf()
|
||||
}
|
||||
@@ -257,5 +234,7 @@ class VpnService : SystemVpnService(), IBaseService,
|
||||
private const val DNS6 = "fdfe:dcba:9876::2"
|
||||
private const val NET_ANY = "0.0.0.0"
|
||||
private const val NET_ANY6 = "::"
|
||||
private const val LOCAL_HOST = "127.0.0.1"
|
||||
private const val MTU = 9000
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-30
@@ -1,36 +1,7 @@
|
||||
package com.follow.clash.service.models
|
||||
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
|
||||
data class NotificationParams(
|
||||
val title: String = "FlClash",
|
||||
val stopText: String = "STOP",
|
||||
val onlyStatisticsProxy: Boolean = false,
|
||||
) : Parcelable {
|
||||
constructor(parcel: Parcel) : this(
|
||||
title = parcel.readString() ?: "FlClash",
|
||||
stopText = parcel.readString() ?: "STOP",
|
||||
onlyStatisticsProxy = parcel.readByte() != 0.toByte(),
|
||||
)
|
||||
|
||||
override fun writeToParcel(parcel: Parcel, flags: Int) {
|
||||
parcel.writeString(title)
|
||||
parcel.writeString(stopText)
|
||||
parcel.writeByte(if (onlyStatisticsProxy) 1.toByte() else 0.toByte())
|
||||
}
|
||||
|
||||
override fun describeContents(): Int {
|
||||
return 0
|
||||
}
|
||||
|
||||
companion object CREATOR : Parcelable.Creator<NotificationParams> {
|
||||
override fun createFromParcel(parcel: Parcel): NotificationParams {
|
||||
return NotificationParams(parcel)
|
||||
}
|
||||
|
||||
override fun newArray(size: Int): Array<NotificationParams?> {
|
||||
return arrayOfNulls(size)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,25 +1,39 @@
|
||||
package com.follow.clash.service.models
|
||||
|
||||
import com.follow.clash.common.GlobalState
|
||||
import com.follow.clash.common.formatBytes
|
||||
import com.follow.clash.core.Core
|
||||
import com.google.gson.Gson
|
||||
|
||||
private val gson = Gson()
|
||||
|
||||
data class Traffic(
|
||||
val up: Long,
|
||||
val down: Long,
|
||||
)
|
||||
|
||||
private val Long.formatBytes: String
|
||||
get() {
|
||||
val units = arrayOf("B", "KB", "MB", "GB", "TB")
|
||||
var value = toDouble()
|
||||
var unit = 0
|
||||
while (value >= 1024 && unit < units.lastIndex) {
|
||||
value /= 1024
|
||||
unit++
|
||||
}
|
||||
return if (unit == 0) {
|
||||
"${value.toLong()}${units[unit]}"
|
||||
} else {
|
||||
"%.1f${units[unit]}".format(value)
|
||||
}
|
||||
}
|
||||
|
||||
val Traffic.speedText: String
|
||||
get() = "${up.formatBytes}/s↑ ${down.formatBytes}/s↓"
|
||||
|
||||
fun Core.getSpeedTrafficText(onlyStatisticsProxy: Boolean): String {
|
||||
try {
|
||||
val res = getTraffic(onlyStatisticsProxy)
|
||||
val traffic = Gson().fromJson(res, Traffic::class.java)
|
||||
return traffic.speedText
|
||||
} catch (e: Exception) {
|
||||
GlobalState.log(e.message + "")
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return runCatching {
|
||||
gson.fromJson(getTraffic(onlyStatisticsProxy), Traffic::class.java).speedText
|
||||
}.onFailure { error ->
|
||||
GlobalState.log("Unable to read traffic: $error")
|
||||
}.getOrDefault("")
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package com.follow.clash.service.models
|
||||
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import com.follow.clash.common.AccessControlMode
|
||||
import java.net.Inet4Address
|
||||
import java.net.Inet6Address
|
||||
import java.net.InetAddress
|
||||
|
||||
data class AccessControlProps(
|
||||
@@ -10,35 +10,7 @@ data class AccessControlProps(
|
||||
val mode: AccessControlMode,
|
||||
val acceptList: List<String>,
|
||||
val rejectList: List<String>,
|
||||
) : Parcelable {
|
||||
constructor(parcel: Parcel) : this(
|
||||
enable = parcel.readByte() != 0.toByte(),
|
||||
mode = AccessControlMode.valueOf(parcel.readString() ?: AccessControlMode.ACCEPT_SELECTED.name),
|
||||
acceptList = parcel.createStringArrayList() ?: emptyList(),
|
||||
rejectList = parcel.createStringArrayList() ?: emptyList(),
|
||||
)
|
||||
|
||||
override fun writeToParcel(parcel: Parcel, flags: Int) {
|
||||
parcel.writeByte(if (enable) 1.toByte() else 0.toByte())
|
||||
parcel.writeString(mode.name)
|
||||
parcel.writeStringList(acceptList)
|
||||
parcel.writeStringList(rejectList)
|
||||
}
|
||||
|
||||
override fun describeContents(): Int {
|
||||
return 0
|
||||
}
|
||||
|
||||
companion object CREATOR : Parcelable.Creator<AccessControlProps> {
|
||||
override fun createFromParcel(parcel: Parcel): AccessControlProps {
|
||||
return AccessControlProps(parcel)
|
||||
}
|
||||
|
||||
override fun newArray(size: Int): Array<AccessControlProps?> {
|
||||
return arrayOfNulls(size)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
data class VpnOptions(
|
||||
val enable: Boolean,
|
||||
@@ -51,110 +23,32 @@ data class VpnOptions(
|
||||
val bypassDomain: List<String>,
|
||||
val stack: String,
|
||||
val routeAddress: List<String>,
|
||||
) : Parcelable {
|
||||
constructor(parcel: Parcel) : this(
|
||||
enable = parcel.readByte() != 0.toByte(),
|
||||
port = parcel.readInt(),
|
||||
ipv6 = parcel.readByte() != 0.toByte(),
|
||||
dnsHijacking = parcel.readByte() != 0.toByte(),
|
||||
accessControlProps = readAccessControlProps(parcel),
|
||||
allowBypass = parcel.readByte() != 0.toByte(),
|
||||
systemProxy = parcel.readByte() != 0.toByte(),
|
||||
bypassDomain = parcel.createStringArrayList() ?: emptyList(),
|
||||
stack = parcel.readString() ?: "",
|
||||
routeAddress = parcel.createStringArrayList() ?: emptyList(),
|
||||
)
|
||||
)
|
||||
|
||||
override fun writeToParcel(parcel: Parcel, flags: Int) {
|
||||
parcel.writeByte(if (enable) 1.toByte() else 0.toByte())
|
||||
parcel.writeInt(port)
|
||||
parcel.writeByte(if (ipv6) 1.toByte() else 0.toByte())
|
||||
parcel.writeByte(if (dnsHijacking) 1.toByte() else 0.toByte())
|
||||
parcel.writeParcelable(accessControlProps, flags)
|
||||
parcel.writeByte(if (allowBypass) 1.toByte() else 0.toByte())
|
||||
parcel.writeByte(if (systemProxy) 1.toByte() else 0.toByte())
|
||||
parcel.writeStringList(bypassDomain)
|
||||
parcel.writeString(stack)
|
||||
parcel.writeStringList(routeAddress)
|
||||
}
|
||||
data class CIDR(
|
||||
val address: InetAddress,
|
||||
val prefixLength: Int,
|
||||
)
|
||||
|
||||
override fun describeContents(): Int {
|
||||
return 0
|
||||
}
|
||||
fun VpnOptions.getIpv4RouteAddress(): List<CIDR> = routeAddress
|
||||
.map(String::toCIDR)
|
||||
.filter { it.address is Inet4Address }
|
||||
|
||||
companion object CREATOR : Parcelable.Creator<VpnOptions> {
|
||||
@Suppress("DEPRECATION")
|
||||
private fun readAccessControlProps(parcel: Parcel): AccessControlProps {
|
||||
return parcel.readParcelable<AccessControlProps>(
|
||||
AccessControlProps::class.java.classLoader,
|
||||
) ?: AccessControlProps(
|
||||
enable = false,
|
||||
mode = AccessControlMode.ACCEPT_SELECTED,
|
||||
acceptList = emptyList(),
|
||||
rejectList = emptyList(),
|
||||
)
|
||||
}
|
||||
|
||||
override fun createFromParcel(parcel: Parcel): VpnOptions {
|
||||
return VpnOptions(parcel)
|
||||
}
|
||||
|
||||
override fun newArray(size: Int): Array<VpnOptions?> {
|
||||
return arrayOfNulls(size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class CIDR(val address: InetAddress, val prefixLength: Int)
|
||||
|
||||
fun VpnOptions.getIpv4RouteAddress(): List<CIDR> {
|
||||
return routeAddress.filter {
|
||||
it.isIpv4()
|
||||
}.map {
|
||||
it.toCIDR()
|
||||
}
|
||||
}
|
||||
|
||||
fun VpnOptions.getIpv6RouteAddress(): List<CIDR> {
|
||||
return routeAddress.filter {
|
||||
it.isIpv6()
|
||||
}.map {
|
||||
it.toCIDR()
|
||||
}
|
||||
}
|
||||
|
||||
fun String.isIpv4(): Boolean {
|
||||
val parts = split("/")
|
||||
if (parts.size != 2) {
|
||||
throw IllegalArgumentException("Invalid CIDR format")
|
||||
}
|
||||
val address = InetAddress.getByName(parts[0])
|
||||
return address.address.size == 4
|
||||
}
|
||||
|
||||
fun String.isIpv6(): Boolean {
|
||||
val parts = split("/")
|
||||
if (parts.size != 2) {
|
||||
throw IllegalArgumentException("Invalid CIDR format")
|
||||
}
|
||||
val address = InetAddress.getByName(parts[0])
|
||||
return address.address.size == 16
|
||||
}
|
||||
fun VpnOptions.getIpv6RouteAddress(): List<CIDR> = routeAddress
|
||||
.map(String::toCIDR)
|
||||
.filter { it.address is Inet6Address }
|
||||
|
||||
fun String.toCIDR(): CIDR {
|
||||
val parts = split("/")
|
||||
if (parts.size != 2) {
|
||||
throw IllegalArgumentException("Invalid CIDR format")
|
||||
}
|
||||
require(parts.size == 2) { "Invalid CIDR format: $this" }
|
||||
val ipAddress = parts[0]
|
||||
val prefixLength =
|
||||
parts[1].toIntOrNull() ?: throw IllegalArgumentException("Invalid prefix length")
|
||||
val prefixLength = parts[1].toIntOrNull()
|
||||
?: throw IllegalArgumentException("Invalid prefix length: ${parts[1]}")
|
||||
|
||||
val address = InetAddress.getByName(ipAddress)
|
||||
|
||||
val maxPrefix = if (address.address.size == 4) 32 else 128
|
||||
if (prefixLength < 0 || prefixLength > maxPrefix) {
|
||||
throw IllegalArgumentException("Invalid prefix length for IP version")
|
||||
require(prefixLength in 0..maxPrefix) {
|
||||
"Invalid prefix length $prefixLength for $ipAddress"
|
||||
}
|
||||
|
||||
return CIDR(address, prefixLength)
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
package com.follow.clash.service.modules
|
||||
|
||||
abstract class Module {
|
||||
|
||||
private var isInstall: Boolean = false
|
||||
|
||||
protected abstract fun onInstall()
|
||||
protected abstract fun onUninstall()
|
||||
|
||||
fun install() {
|
||||
isInstall = true
|
||||
onInstall()
|
||||
}
|
||||
|
||||
fun uninstall() {
|
||||
onUninstall()
|
||||
isInstall = false
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package com.follow.clash.service.modules
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
interface ModuleLoaderScope {
|
||||
fun <T : Module> install(module: T): T
|
||||
}
|
||||
|
||||
interface ModuleLoader {
|
||||
fun load()
|
||||
|
||||
fun cancel()
|
||||
}
|
||||
|
||||
private val mutex = Mutex()
|
||||
fun CoroutineScope.moduleLoader(block: suspend ModuleLoaderScope.() -> Unit): ModuleLoader {
|
||||
val modules = mutableListOf<Module>()
|
||||
var job: Job? = null
|
||||
|
||||
return object : ModuleLoader {
|
||||
override fun load() {
|
||||
job = launch(Dispatchers.IO) {
|
||||
mutex.withLock {
|
||||
val scope = object : ModuleLoaderScope {
|
||||
override fun <T : Module> install(module: T): T {
|
||||
modules.add(module)
|
||||
module.install()
|
||||
return module
|
||||
}
|
||||
}
|
||||
scope.block()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun cancel() {
|
||||
launch(Dispatchers.IO) {
|
||||
job?.cancel()
|
||||
mutex.withLock {
|
||||
modules.asReversed().forEach { it.uninstall() }
|
||||
modules.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+46
-73
@@ -17,18 +17,20 @@ import java.net.InetAddress
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
private data class NetworkInfo(
|
||||
@Volatile var losingMs: Long = 0, @Volatile var dnsList: List<InetAddress> = emptyList()
|
||||
@Volatile var losingUntilMillis: Long = 0,
|
||||
@Volatile var dnsList: List<InetAddress> = emptyList(),
|
||||
) {
|
||||
fun isAvailable(): Boolean = losingMs < System.currentTimeMillis()
|
||||
val priorityPenalty: Int
|
||||
get() = if (losingUntilMillis > System.currentTimeMillis()) 10 else 0
|
||||
}
|
||||
|
||||
class NetworkObserveModule(private val service: Service) : Module() {
|
||||
internal class NetworkObserveModule(private val service: Service) : ServiceModule {
|
||||
|
||||
private val networkInfos = ConcurrentHashMap<Network, NetworkInfo>()
|
||||
private val connectivity by lazy {
|
||||
service.getSystemService<ConnectivityManager>()
|
||||
}
|
||||
private var preDnsList = listOf<String>()
|
||||
private var currentDnsList = listOf<String>()
|
||||
|
||||
private val request = NetworkRequest.Builder().apply {
|
||||
addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
|
||||
@@ -42,108 +44,79 @@ class NetworkObserveModule(private val service: Service) : Module() {
|
||||
private val callback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
networkInfos[network] = NetworkInfo()
|
||||
onUpdateNetwork()
|
||||
super.onAvailable(network)
|
||||
updateDns()
|
||||
}
|
||||
|
||||
override fun onLosing(network: Network, maxMsToLive: Int) {
|
||||
networkInfos[network]?.losingMs = System.currentTimeMillis() + maxMsToLive
|
||||
onUpdateNetwork()
|
||||
setUnderlyingNetworks(network)
|
||||
super.onLosing(network, maxMsToLive)
|
||||
networkInfos[network]?.losingUntilMillis = System.currentTimeMillis() + maxMsToLive
|
||||
updateDns()
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
networkInfos.remove(network)
|
||||
onUpdateNetwork()
|
||||
setUnderlyingNetworks(network)
|
||||
super.onLost(network)
|
||||
updateDns()
|
||||
}
|
||||
|
||||
override fun onLinkPropertiesChanged(network: Network, linkProperties: LinkProperties) {
|
||||
networkInfos[network]?.dnsList = linkProperties.dnsServers
|
||||
onUpdateNetwork()
|
||||
setUnderlyingNetworks(network)
|
||||
super.onLinkPropertiesChanged(network, linkProperties)
|
||||
updateDns()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun onInstall() {
|
||||
onUpdateNetwork()
|
||||
override fun start() {
|
||||
updateDns()
|
||||
connectivity?.registerNetworkCallback(request, callback)
|
||||
}
|
||||
|
||||
private fun networkToInt(entry: Map.Entry<Network, NetworkInfo>): Int {
|
||||
private fun networkPriority(entry: Map.Entry<Network, NetworkInfo>): Int {
|
||||
val capabilities = connectivity?.getNetworkCapabilities(entry.key)
|
||||
return when {
|
||||
capabilities == null -> 100
|
||||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> 90
|
||||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> 0
|
||||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> 1
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && capabilities.hasTransport(
|
||||
TRANSPORT_USB
|
||||
) -> 2
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
|
||||
capabilities.hasTransport(TRANSPORT_USB) -> 2
|
||||
|
||||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_BLUETOOTH) -> 3
|
||||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> 4
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM && capabilities.hasTransport(
|
||||
TRANSPORT_SATELLITE
|
||||
) -> 5
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM &&
|
||||
capabilities.hasTransport(TRANSPORT_SATELLITE) -> 5
|
||||
|
||||
else -> 20
|
||||
} + (if (entry.value.isAvailable()) 0 else 10)
|
||||
} + entry.value.priorityPenalty
|
||||
}
|
||||
|
||||
fun onUpdateNetwork() {
|
||||
val dnsList = (networkInfos.asSequence().minByOrNull { networkToInt(it) }?.value?.dnsList
|
||||
?: emptyList()).map { x -> x.asSocketAddressText(53) }
|
||||
if (dnsList == preDnsList) {
|
||||
@Synchronized
|
||||
private fun updateDns() {
|
||||
val dnsList = networkInfos.asSequence()
|
||||
.minByOrNull(::networkPriority)
|
||||
?.value
|
||||
?.dnsList
|
||||
.orEmpty()
|
||||
.map { address -> address.asSocketAddressText(DNS_PORT) }
|
||||
.distinct()
|
||||
if (dnsList == currentDnsList) {
|
||||
return
|
||||
}
|
||||
preDnsList = dnsList
|
||||
Core.updateDNS(dnsList.toSet().joinToString(","))
|
||||
currentDnsList = dnsList
|
||||
Core.updateDNS(dnsList.joinToString(","))
|
||||
}
|
||||
|
||||
fun setUnderlyingNetworks(network: Network) {
|
||||
// if (service is VpnService && Build.VERSION.SDK_INT in 22..28) {
|
||||
// service.setUnderlyingNetworks(arrayOf(network))
|
||||
// }
|
||||
}
|
||||
|
||||
override fun onUninstall() {
|
||||
connectivity?.unregisterNetworkCallback(callback)
|
||||
networkInfos.clear()
|
||||
onUpdateNetwork()
|
||||
}
|
||||
}
|
||||
|
||||
fun InetAddress.asSocketAddressText(port: Int): String {
|
||||
return when (this) {
|
||||
is Inet6Address -> "[${numericToTextFormat(this)}]:$port"
|
||||
|
||||
is Inet4Address -> "${this.hostAddress}:$port"
|
||||
|
||||
else -> throw IllegalArgumentException("Unsupported Inet type ${this.javaClass}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun numericToTextFormat(address: Inet6Address): String {
|
||||
val src = address.address
|
||||
val sb = StringBuilder(39)
|
||||
for (i in 0 until 8) {
|
||||
sb.append(
|
||||
Integer.toHexString(
|
||||
src[i shl 1].toInt() shl 8 and 0xff00 or (src[(i shl 1) + 1].toInt() and 0xff)
|
||||
)
|
||||
)
|
||||
if (i < 7) {
|
||||
sb.append(":")
|
||||
override fun stop() {
|
||||
try {
|
||||
connectivity?.unregisterNetworkCallback(callback)
|
||||
} finally {
|
||||
networkInfos.clear()
|
||||
updateDns()
|
||||
}
|
||||
}
|
||||
if (address.scopeId > 0) {
|
||||
sb.append("%")
|
||||
sb.append(address.scopeId)
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
}
|
||||
|
||||
private const val DNS_PORT = 53
|
||||
|
||||
private fun InetAddress.asSocketAddressText(port: Int): String = when (this) {
|
||||
is Inet6Address -> "[$hostAddress]:$port"
|
||||
is Inet4Address -> "$hostAddress:$port"
|
||||
else -> error("Unsupported address type: ${javaClass.name}")
|
||||
}
|
||||
|
||||
+42
-45
@@ -14,39 +14,41 @@ import com.follow.clash.common.QuickAction
|
||||
import com.follow.clash.common.quickIntent
|
||||
import com.follow.clash.common.receiveBroadcastFlow
|
||||
import com.follow.clash.common.startForeground
|
||||
import com.follow.clash.common.tickerFlow
|
||||
import com.follow.clash.common.toPendingIntent
|
||||
import com.follow.clash.core.Core
|
||||
import com.follow.clash.service.R
|
||||
import com.follow.clash.service.State
|
||||
import com.follow.clash.service.ServiceConfig
|
||||
import com.follow.clash.service.models.NotificationParams
|
||||
import com.follow.clash.service.models.getSpeedTrafficText
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class ExtendedNotificationParams(
|
||||
private data class ExtendedNotificationParams(
|
||||
val title: String,
|
||||
val stopText: String,
|
||||
val onlyStatisticsProxy: Boolean,
|
||||
val contentText: String,
|
||||
)
|
||||
|
||||
val NotificationParams.extended: ExtendedNotificationParams
|
||||
private val NotificationParams.extended: ExtendedNotificationParams
|
||||
get() = ExtendedNotificationParams(
|
||||
title, stopText, onlyStatisticsProxy, Core.getSpeedTrafficText(onlyStatisticsProxy)
|
||||
title,
|
||||
stopText,
|
||||
Core.getSpeedTrafficText(onlyStatisticsProxy),
|
||||
)
|
||||
|
||||
class NotificationModule(private val service: Service) : Module() {
|
||||
private val scope = CoroutineScope(Dispatchers.Default)
|
||||
|
||||
override fun onInstall() {
|
||||
internal class NotificationModule(
|
||||
private val service: Service,
|
||||
private val scope: CoroutineScope,
|
||||
) : ServiceModule {
|
||||
override fun start() {
|
||||
update(ServiceConfig.notificationParams.value.extended)
|
||||
scope.launch {
|
||||
val screenFlow = service.receiveBroadcastFlow {
|
||||
addAction(Intent.ACTION_SCREEN_ON)
|
||||
@@ -58,41 +60,36 @@ class NotificationModule(private val service: Service) : Module() {
|
||||
}
|
||||
|
||||
combine(
|
||||
tickerFlow(1000, 0), State.notificationParamsFlow, screenFlow
|
||||
flow {
|
||||
while (true) {
|
||||
delay(1_000)
|
||||
emit(Unit)
|
||||
}
|
||||
},
|
||||
ServiceConfig.notificationParams,
|
||||
screenFlow,
|
||||
) { _, params, screenOn ->
|
||||
params?.extended to screenOn
|
||||
}.filter { (params, screenOn) -> params != null && screenOn }
|
||||
.distinctUntilChanged { old, new -> old.first == new.first && old.second == new.second }
|
||||
.collect { (params, _) ->
|
||||
update(params!!)
|
||||
}
|
||||
|
||||
State.notificationParamsFlow.value?.let {
|
||||
update(it.extended)
|
||||
} ?: run {
|
||||
update(NotificationParams().extended)
|
||||
}
|
||||
params.takeIf { screenOn }?.extended
|
||||
}.filterNotNull()
|
||||
.distinctUntilChanged()
|
||||
.collect(::update)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isScreenOn(): Boolean {
|
||||
val pm = service.getSystemService<PowerManager>()
|
||||
return when (pm != null) {
|
||||
true -> pm.isInteractive
|
||||
false -> true
|
||||
}
|
||||
}
|
||||
private fun isScreenOn() =
|
||||
service.getSystemService<PowerManager>()?.isInteractive ?: true
|
||||
|
||||
private val notificationBuilder: NotificationCompat.Builder by lazy {
|
||||
val intent = Intent().setComponent(Components.MAIN_ACTIVITY)
|
||||
val intent = Intent().setComponent(Components.mainActivity)
|
||||
|
||||
NotificationCompat.Builder(
|
||||
service, GlobalState.NOTIFICATION_CHANNEL
|
||||
service,
|
||||
GlobalState.NOTIFICATION_CHANNEL,
|
||||
).apply {
|
||||
setSmallIcon(R.drawable.ic_service)
|
||||
setContentTitle("FlClash")
|
||||
setContentIntent(intent.toPendingIntent)
|
||||
setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
setOngoing(true)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
@@ -100,9 +97,6 @@ class NotificationModule(private val service: Service) : Module() {
|
||||
}
|
||||
setShowWhen(true)
|
||||
setOnlyAlertOnce(true)
|
||||
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
// setRequestPromotedOngoing(true)
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,17 +107,20 @@ class NotificationModule(private val service: Service) : Module() {
|
||||
setContentText(params.contentText)
|
||||
clearActions()
|
||||
addAction(
|
||||
0, params.stopText, QuickAction.STOP.quickIntent.toPendingIntent
|
||||
0,
|
||||
params.stopText,
|
||||
QuickAction.STOP.quickIntent.toPendingIntent,
|
||||
).build()
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
override fun onUninstall() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
@Suppress("DEPRECATION")
|
||||
override fun stop() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
service.stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
} else {
|
||||
service.stopForeground(true)
|
||||
}
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.follow.clash.service.modules
|
||||
|
||||
import android.app.Service
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
|
||||
internal interface ServiceModule {
|
||||
fun start()
|
||||
|
||||
fun stop()
|
||||
}
|
||||
|
||||
internal class ServiceModules(private val service: Service) {
|
||||
private var scope: CoroutineScope? = null
|
||||
private var modules = emptyList<ServiceModule>()
|
||||
|
||||
@Synchronized
|
||||
fun start() {
|
||||
if (scope != null) return
|
||||
|
||||
val nextScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
val nextModules = listOf(
|
||||
NotificationModule(service, nextScope),
|
||||
NetworkObserveModule(service),
|
||||
SuspendModule(service, nextScope),
|
||||
)
|
||||
val startedModules = mutableListOf<ServiceModule>()
|
||||
|
||||
try {
|
||||
nextModules.forEach { module ->
|
||||
module.start()
|
||||
startedModules.add(module)
|
||||
}
|
||||
scope = nextScope
|
||||
modules = nextModules
|
||||
} catch (error: Throwable) {
|
||||
nextScope.cancel()
|
||||
startedModules.asReversed().forEach { module ->
|
||||
runCatching { module.stop() }
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun stop() {
|
||||
val currentScope = scope ?: return
|
||||
val currentModules = modules
|
||||
scope = null
|
||||
modules = emptyList()
|
||||
|
||||
currentScope.cancel()
|
||||
currentModules.asReversed().forEach { module ->
|
||||
runCatching { module.stop() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,55 +7,41 @@ import androidx.core.content.getSystemService
|
||||
import com.follow.clash.common.receiveBroadcastFlow
|
||||
import com.follow.clash.core.Core
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
internal class SuspendModule(
|
||||
private val service: Service,
|
||||
private val scope: CoroutineScope,
|
||||
) : ServiceModule {
|
||||
private fun isScreenOn() =
|
||||
service.getSystemService<PowerManager>()?.isInteractive ?: true
|
||||
|
||||
class SuspendModule(private val service: Service) : Module() {
|
||||
private val scope = CoroutineScope(Dispatchers.Default)
|
||||
private val isDeviceIdle: Boolean
|
||||
get() = service.getSystemService<PowerManager>()?.isDeviceIdleMode ?: true
|
||||
|
||||
private fun isScreenOn(): Boolean {
|
||||
val pm = service.getSystemService<PowerManager>()
|
||||
return when (pm != null) {
|
||||
true -> pm.isInteractive
|
||||
false -> true
|
||||
}
|
||||
private fun updateSuspension(screenOn: Boolean) {
|
||||
Core.suspended(!screenOn && isDeviceIdle)
|
||||
}
|
||||
|
||||
val isDeviceIdleMode: Boolean
|
||||
get() {
|
||||
return service.getSystemService<PowerManager>()?.isDeviceIdleMode ?: true
|
||||
}
|
||||
|
||||
private fun onUpdate(isScreenOn: Boolean) {
|
||||
if (isScreenOn) {
|
||||
Core.suspended(false)
|
||||
return
|
||||
}
|
||||
Core.suspended(isDeviceIdleMode)
|
||||
}
|
||||
|
||||
override fun onInstall() {
|
||||
override fun start() {
|
||||
scope.launch {
|
||||
val screenFlow = service.receiveBroadcastFlow {
|
||||
addAction(Intent.ACTION_SCREEN_ON)
|
||||
addAction(Intent.ACTION_SCREEN_OFF)
|
||||
}.map { intent ->
|
||||
intent.action == Intent.ACTION_SCREEN_ON
|
||||
addAction(PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED)
|
||||
}.map {
|
||||
isScreenOn()
|
||||
}.onStart {
|
||||
emit(isScreenOn())
|
||||
}
|
||||
|
||||
screenFlow.collect {
|
||||
onUpdate(it)
|
||||
}
|
||||
screenFlow.collect(::updateSuspension)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onUninstall() {
|
||||
scope.cancel()
|
||||
override fun stop() {
|
||||
Core.suspended(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,11 +21,10 @@ plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "9.0.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.4.0" apply false
|
||||
id("com.google.gms.google-services") version ("4.5.0") apply false
|
||||
id("com.google.firebase.crashlytics") version ("3.0.7") apply false
|
||||
id("com.google.gms.google-services") version "4.5.0" apply false
|
||||
id("com.google.firebase.crashlytics") version "3.0.7" apply false
|
||||
}
|
||||
|
||||
|
||||
include(":app")
|
||||
include(":core")
|
||||
include(":service")
|
||||
|
||||
@@ -318,6 +318,8 @@
|
||||
"messageTest": "Message test",
|
||||
"messageTestTip": "This is a message.",
|
||||
"crashTest": "Crash test",
|
||||
"crashDetected": "Crash detected",
|
||||
"crashDetectedTip": "The app crashed during the previous run. To prevent repeated crashes, the current profile has been cleared and automatic configuration setup was skipped.",
|
||||
"clearData": "Clear Data",
|
||||
"textScale": "Text Scaling",
|
||||
"internet": "Internet",
|
||||
|
||||
@@ -318,6 +318,8 @@
|
||||
"messageTest": "メッセージテスト",
|
||||
"messageTestTip": "これはメッセージです。",
|
||||
"crashTest": "クラッシュテスト",
|
||||
"crashDetected": "クラッシュを検出しました",
|
||||
"crashDetectedTip": "前回の実行中にアプリがクラッシュしました。クラッシュの繰り返しを防ぐため、現在のプロファイルを解除し、設定の自動セットアップをスキップしました。",
|
||||
"clearData": "データを消去",
|
||||
"textScale": "テキストスケーリング",
|
||||
"internet": "インターネット",
|
||||
|
||||
@@ -318,6 +318,8 @@
|
||||
"messageTest": "Тестирование сообщения",
|
||||
"messageTestTip": "Это сообщение.",
|
||||
"crashTest": "Тест на сбои",
|
||||
"crashDetected": "Обнаружен сбой",
|
||||
"crashDetectedTip": "Во время предыдущего запуска произошёл сбой приложения. Чтобы предотвратить повторный сбой, текущий профиль был сброшен, а автоматическая настройка конфигурации пропущена.",
|
||||
"clearData": "Очистить данные",
|
||||
"textScale": "Масштабирование текста",
|
||||
"internet": "Интернет",
|
||||
|
||||
@@ -318,6 +318,8 @@
|
||||
"messageTest": "消息测试",
|
||||
"messageTestTip": "这是一条消息。",
|
||||
"crashTest": "崩溃测试",
|
||||
"crashDetected": "检测到崩溃",
|
||||
"crashDetectedTip": "检测到应用上次运行发生崩溃。为避免重复崩溃,已清除当前配置选择,并跳过本次自动配置。",
|
||||
"clearData": "清除数据",
|
||||
"textScale": "文本缩放",
|
||||
"internet": "互联网",
|
||||
|
||||
-193
@@ -1,193 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
type Action struct {
|
||||
Id string `json:"id"`
|
||||
Method Method `json:"method"`
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
|
||||
type ActionResult struct {
|
||||
Id string `json:"id"`
|
||||
Method Method `json:"method"`
|
||||
Data interface{} `json:"data"`
|
||||
Code int `json:"code"`
|
||||
callback unsafe.Pointer
|
||||
}
|
||||
|
||||
func (result ActionResult) Json() ([]byte, error) {
|
||||
data, err := json.Marshal(result)
|
||||
return data, err
|
||||
}
|
||||
|
||||
func (result ActionResult) success(data interface{}) {
|
||||
result.Code = 0
|
||||
result.Data = data
|
||||
result.send()
|
||||
}
|
||||
|
||||
func (result ActionResult) error(data interface{}) {
|
||||
result.Code = -1
|
||||
result.Data = data
|
||||
result.send()
|
||||
}
|
||||
|
||||
func handleAction(action *Action, result ActionResult) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
buf := make([]byte, 4096)
|
||||
n := runtime.Stack(buf, false)
|
||||
logError("panic in handleAction(%s): %v\n%s", action.Method, r, buf[:n])
|
||||
result.error(fmt.Sprintf("internal panic: %v", r))
|
||||
}
|
||||
}()
|
||||
switch action.Method {
|
||||
case initClashMethod:
|
||||
paramsString := action.Data.(string)
|
||||
result.success(handleInitClash(paramsString))
|
||||
return
|
||||
case getIsInitMethod:
|
||||
result.success(handleGetIsInit())
|
||||
return
|
||||
case forceGcMethod:
|
||||
handleForceGC()
|
||||
result.success(true)
|
||||
return
|
||||
case shutdownMethod:
|
||||
result.success(handleShutdown())
|
||||
return
|
||||
case validateConfigMethod:
|
||||
path := action.Data.(string)
|
||||
result.success(handleValidateConfig(path))
|
||||
return
|
||||
case updateConfigMethod:
|
||||
data := []byte(action.Data.(string))
|
||||
result.success(handleUpdateConfig(data))
|
||||
return
|
||||
case setupConfigMethod:
|
||||
data := []byte(action.Data.(string))
|
||||
result.success(handleSetupConfig(data))
|
||||
return
|
||||
case getProxiesMethod:
|
||||
result.success(handleGetProxies())
|
||||
return
|
||||
case changeProxyMethod:
|
||||
data := action.Data.(string)
|
||||
handleChangeProxy(data, func(value string) {
|
||||
result.success(value)
|
||||
})
|
||||
return
|
||||
case getTrafficMethod:
|
||||
data := action.Data.(bool)
|
||||
result.success(handleGetTraffic(data))
|
||||
return
|
||||
case getTotalTrafficMethod:
|
||||
data := action.Data.(bool)
|
||||
result.success(handleGetTotalTraffic(data))
|
||||
return
|
||||
case resetTrafficMethod:
|
||||
handleResetTraffic()
|
||||
result.success(true)
|
||||
return
|
||||
case asyncTestDelayMethod:
|
||||
data := action.Data.(string)
|
||||
handleAsyncTestDelay(data, func(value string) {
|
||||
result.success(value)
|
||||
})
|
||||
return
|
||||
case getConnectionsMethod:
|
||||
result.success(handleGetConnections())
|
||||
return
|
||||
case closeConnectionsMethod:
|
||||
result.success(handleCloseConnections())
|
||||
return
|
||||
case resetConnectionsMethod:
|
||||
result.success(handleResetConnections())
|
||||
return
|
||||
case getConfigMethod:
|
||||
path := action.Data.(string)
|
||||
config, err := handleGetConfig(path)
|
||||
if err != nil {
|
||||
result.error(err)
|
||||
return
|
||||
}
|
||||
result.success(config)
|
||||
return
|
||||
case closeConnectionMethod:
|
||||
id := action.Data.(string)
|
||||
result.success(handleCloseConnection(id))
|
||||
return
|
||||
case getExternalProvidersMethod:
|
||||
result.success(handleGetExternalProviders())
|
||||
return
|
||||
case getExternalProviderMethod:
|
||||
externalProviderName := action.Data.(string)
|
||||
result.success(handleGetExternalProvider(externalProviderName))
|
||||
return
|
||||
case updateGeoDataMethod:
|
||||
geoType := action.Data.(string)
|
||||
handleUpdateGeoData(geoType)
|
||||
result.success("")
|
||||
return
|
||||
case updateExternalProviderMethod:
|
||||
providerName := action.Data.(string)
|
||||
handleUpdateExternalProvider(providerName, func(value string) {
|
||||
result.success(value)
|
||||
})
|
||||
return
|
||||
case sideLoadExternalProviderMethod:
|
||||
paramsString := action.Data.(string)
|
||||
var params = map[string]string{}
|
||||
err := json.Unmarshal([]byte(paramsString), ¶ms)
|
||||
if err != nil {
|
||||
result.success(err.Error())
|
||||
return
|
||||
}
|
||||
providerName := params["providerName"]
|
||||
data := params["data"]
|
||||
handleSideLoadExternalProvider(providerName, []byte(data), func(value string) {
|
||||
result.success(value)
|
||||
})
|
||||
return
|
||||
case startLogMethod:
|
||||
handleStartLog()
|
||||
result.success(true)
|
||||
return
|
||||
case stopLogMethod:
|
||||
handleStopLog()
|
||||
result.success(true)
|
||||
return
|
||||
case startListenerMethod:
|
||||
result.success(handleStartListener())
|
||||
return
|
||||
case stopListenerMethod:
|
||||
result.success(handleStopListener())
|
||||
return
|
||||
case getCountryCodeMethod:
|
||||
ip := action.Data.(string)
|
||||
handleGetCountryCode(ip, func(value string) {
|
||||
result.success(value)
|
||||
})
|
||||
return
|
||||
case getMemoryMethod:
|
||||
handleGetMemory(func(value string) {
|
||||
result.success(value)
|
||||
})
|
||||
return
|
||||
case crashMethod:
|
||||
result.success(true)
|
||||
handleCrash()
|
||||
case deleteFile:
|
||||
path := action.Data.(string)
|
||||
handleDelFile(path, result)
|
||||
return
|
||||
default:
|
||||
nextHandle(action, result)
|
||||
}
|
||||
}
|
||||
+15
-5
@@ -229,11 +229,21 @@ func updateConfig(params *UpdateParams) {
|
||||
|
||||
if params.Tun != nil {
|
||||
general.Tun.Enable = params.Tun.Enable
|
||||
general.Tun.AutoRoute = *params.Tun.AutoRoute
|
||||
general.Tun.Device = *params.Tun.Device
|
||||
general.Tun.RouteAddress = *params.Tun.RouteAddress
|
||||
general.Tun.DNSHijack = *params.Tun.DNSHijack
|
||||
general.Tun.Stack = *params.Tun.Stack
|
||||
if params.Tun.AutoRoute != nil {
|
||||
general.Tun.AutoRoute = *params.Tun.AutoRoute
|
||||
}
|
||||
if params.Tun.Device != nil {
|
||||
general.Tun.Device = *params.Tun.Device
|
||||
}
|
||||
if params.Tun.RouteAddress != nil {
|
||||
general.Tun.RouteAddress = *params.Tun.RouteAddress
|
||||
}
|
||||
if params.Tun.DNSHijack != nil {
|
||||
general.Tun.DNSHijack = *params.Tun.DNSHijack
|
||||
}
|
||||
if params.Tun.Stack != nil {
|
||||
general.Tun.Stack = *params.Tun.Stack
|
||||
}
|
||||
}
|
||||
|
||||
if params.GeoAutoUpdate != nil {
|
||||
|
||||
+41
-36
@@ -48,8 +48,8 @@ type tunSchema struct {
|
||||
}
|
||||
|
||||
type ChangeProxyParams struct {
|
||||
GroupName *string `json:"group-name"`
|
||||
ProxyName *string `json:"proxy-name"`
|
||||
GroupName string `json:"group-name"`
|
||||
ProxyName string `json:"proxy-name"`
|
||||
}
|
||||
|
||||
type TestDelayParams struct {
|
||||
@@ -58,6 +58,11 @@ type TestDelayParams struct {
|
||||
Timeout int64 `json:"timeout"`
|
||||
}
|
||||
|
||||
type Traffic struct {
|
||||
Up int64 `json:"up"`
|
||||
Down int64 `json:"down"`
|
||||
}
|
||||
|
||||
type ExternalProvider struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
@@ -74,42 +79,42 @@ type ProxiesData struct {
|
||||
}
|
||||
|
||||
const (
|
||||
messageMethod Method = "message"
|
||||
initClashMethod Method = "initClash"
|
||||
getIsInitMethod Method = "getIsInit"
|
||||
forceGcMethod Method = "forceGc"
|
||||
shutdownMethod Method = "shutdown"
|
||||
validateConfigMethod Method = "validateConfig"
|
||||
updateConfigMethod Method = "updateConfig"
|
||||
getProxiesMethod Method = "getProxies"
|
||||
changeProxyMethod Method = "changeProxy"
|
||||
getTrafficMethod Method = "getTraffic"
|
||||
getTotalTrafficMethod Method = "getTotalTraffic"
|
||||
resetTrafficMethod Method = "resetTraffic"
|
||||
asyncTestDelayMethod Method = "asyncTestDelay"
|
||||
getConnectionsMethod Method = "getConnections"
|
||||
closeConnectionsMethod Method = "closeConnections"
|
||||
resetConnectionsMethod Method = "resetConnections"
|
||||
closeConnectionMethod Method = "closeConnection"
|
||||
getExternalProvidersMethod Method = "getExternalProviders"
|
||||
getExternalProviderMethod Method = "getExternalProvider"
|
||||
getCountryCodeMethod Method = "getCountryCode"
|
||||
getMemoryMethod Method = "getMemory"
|
||||
updateGeoDataMethod Method = "updateGeoData"
|
||||
updateExternalProviderMethod Method = "updateExternalProvider"
|
||||
sideLoadExternalProviderMethod Method = "sideLoadExternalProvider"
|
||||
startLogMethod Method = "startLog"
|
||||
stopLogMethod Method = "stopLog"
|
||||
startListenerMethod Method = "startListener"
|
||||
stopListenerMethod Method = "stopListener"
|
||||
updateDnsMethod Method = "updateDns"
|
||||
crashMethod Method = "crash"
|
||||
setupConfigMethod Method = "setupConfig"
|
||||
getConfigMethod Method = "getConfig"
|
||||
deleteFile Method = "deleteFile"
|
||||
messageMethod CoreMethod = "message"
|
||||
initClashMethod CoreMethod = "initClash"
|
||||
getIsInitMethod CoreMethod = "getIsInit"
|
||||
forceGcMethod CoreMethod = "forceGc"
|
||||
shutdownMethod CoreMethod = "shutdown"
|
||||
validateConfigMethod CoreMethod = "validateConfig"
|
||||
updateConfigMethod CoreMethod = "updateConfig"
|
||||
getProxiesMethod CoreMethod = "getProxies"
|
||||
changeProxyMethod CoreMethod = "changeProxy"
|
||||
getTrafficMethod CoreMethod = "getTraffic"
|
||||
getTotalTrafficMethod CoreMethod = "getTotalTraffic"
|
||||
resetTrafficMethod CoreMethod = "resetTraffic"
|
||||
asyncTestDelayMethod CoreMethod = "asyncTestDelay"
|
||||
getConnectionsMethod CoreMethod = "getConnections"
|
||||
closeConnectionsMethod CoreMethod = "closeConnections"
|
||||
resetConnectionsMethod CoreMethod = "resetConnections"
|
||||
closeConnectionMethod CoreMethod = "closeConnection"
|
||||
getExternalProvidersMethod CoreMethod = "getExternalProviders"
|
||||
getExternalProviderMethod CoreMethod = "getExternalProvider"
|
||||
getCountryCodeMethod CoreMethod = "getCountryCode"
|
||||
getMemoryMethod CoreMethod = "getMemory"
|
||||
updateGeoDataMethod CoreMethod = "updateGeoData"
|
||||
updateExternalProviderMethod CoreMethod = "updateExternalProvider"
|
||||
sideLoadExternalProviderMethod CoreMethod = "sideLoadExternalProvider"
|
||||
startLogMethod CoreMethod = "startLog"
|
||||
stopLogMethod CoreMethod = "stopLog"
|
||||
startListenerMethod CoreMethod = "startListener"
|
||||
stopListenerMethod CoreMethod = "stopListener"
|
||||
updateDnsMethod CoreMethod = "updateDns"
|
||||
crashMethod CoreMethod = "crash"
|
||||
setupConfigMethod CoreMethod = "setupConfig"
|
||||
getConfigMethod CoreMethod = "getConfig"
|
||||
deleteFileMethod CoreMethod = "deleteFile"
|
||||
)
|
||||
|
||||
type Method string
|
||||
type CoreMethod string
|
||||
|
||||
type MessageType string
|
||||
|
||||
|
||||
+57
-115
@@ -3,7 +3,6 @@ package main
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"github.com/metacubex/mihomo/adapter"
|
||||
"github.com/metacubex/mihomo/adapter/outboundgroup"
|
||||
"github.com/metacubex/mihomo/common/observable"
|
||||
@@ -25,7 +24,6 @@ import (
|
||||
"os"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -35,14 +33,9 @@ var (
|
||||
logSubscriber observable.Subscription[log.Event]
|
||||
)
|
||||
|
||||
func handleInitClash(paramsString string) bool {
|
||||
func handleInitClash(params *InitParams) bool {
|
||||
runLock.Lock()
|
||||
defer runLock.Unlock()
|
||||
var params = InitParams{}
|
||||
err := json.Unmarshal([]byte(paramsString), ¶ms)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
version = params.Version
|
||||
constant.SetHomeDir(params.HomeDir)
|
||||
isInit = true
|
||||
@@ -136,25 +129,23 @@ func handleGetProxies() ProxiesData {
|
||||
}
|
||||
}
|
||||
|
||||
func handleChangeProxy(data string, fn func(string string)) {
|
||||
func handleChangeProxy(params *ChangeProxyParams, fn func(string string)) {
|
||||
runLock.Lock()
|
||||
go func() {
|
||||
defer runLock.Unlock()
|
||||
var params = &ChangeProxyParams{}
|
||||
err := json.Unmarshal([]byte(data), params)
|
||||
if err != nil {
|
||||
fn(err.Error())
|
||||
return
|
||||
}
|
||||
groupName := *params.GroupName
|
||||
proxyName := *params.ProxyName
|
||||
groupName := params.GroupName
|
||||
proxyName := params.ProxyName
|
||||
proxies := tunnel.AllProxies()
|
||||
group, ok := proxies[groupName]
|
||||
if !ok {
|
||||
fn("Not found group")
|
||||
return
|
||||
}
|
||||
adapterProxy := group.(*adapter.Proxy)
|
||||
adapterProxy, ok := group.(*adapter.Proxy)
|
||||
if !ok {
|
||||
fn("Group has invalid proxy type")
|
||||
return
|
||||
}
|
||||
selector, ok := adapterProxy.ProxyAdapter.(outboundgroup.SelectAble)
|
||||
if !ok {
|
||||
fn("Group is not selectable")
|
||||
@@ -163,11 +154,11 @@ func handleChangeProxy(data string, fn func(string string)) {
|
||||
if proxyName == "" {
|
||||
selector.ForceSet(proxyName)
|
||||
} else {
|
||||
err = selector.Set(proxyName)
|
||||
}
|
||||
if err != nil {
|
||||
fn(err.Error())
|
||||
return
|
||||
err := selector.Set(proxyName)
|
||||
if err != nil {
|
||||
fn(err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
fn("")
|
||||
@@ -175,50 +166,42 @@ func handleChangeProxy(data string, fn func(string string)) {
|
||||
}()
|
||||
}
|
||||
|
||||
func handleGetTraffic(onlyStatisticsProxy bool) string {
|
||||
func handleGetTraffic(onlyStatisticsProxy bool) Traffic {
|
||||
up, down := statistic.DefaultManager.NowTraffic(onlyStatisticsProxy)
|
||||
traffic := map[string]int64{
|
||||
"up": up,
|
||||
"down": down,
|
||||
return Traffic{
|
||||
Up: up,
|
||||
Down: down,
|
||||
}
|
||||
data, err := json.Marshal(traffic)
|
||||
if err != nil {
|
||||
logError("Error: %s", err)
|
||||
return ""
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func handleGetTotalTraffic(onlyStatisticsProxy bool) string {
|
||||
func handleGetTotalTraffic(onlyStatisticsProxy bool) Traffic {
|
||||
up, down := statistic.DefaultManager.TotalTraffic(onlyStatisticsProxy)
|
||||
traffic := map[string]int64{
|
||||
"up": up,
|
||||
"down": down,
|
||||
return Traffic{
|
||||
Up: up,
|
||||
Down: down,
|
||||
}
|
||||
data, err := json.Marshal(traffic)
|
||||
if err != nil {
|
||||
logError("Error: %s", err)
|
||||
return ""
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func handleResetTraffic() {
|
||||
statistic.DefaultManager.ResetStatistic()
|
||||
}
|
||||
|
||||
func handleAsyncTestDelay(paramsString string, fn func(string)) {
|
||||
mBatch.Go(paramsString, func() (bool, error) {
|
||||
var params = &TestDelayParams{}
|
||||
err := json.Unmarshal([]byte(paramsString), params)
|
||||
if err != nil {
|
||||
fn("")
|
||||
return false, nil
|
||||
func handleAsyncTestDelay(params *TestDelayParams, fn func(*Delay)) {
|
||||
batchKey := params.ProxyName + "\x00" + params.TestUrl
|
||||
mBatch.Go(batchKey, func() (bool, error) {
|
||||
testUrl := params.TestUrl
|
||||
if testUrl == "" {
|
||||
testUrl = constant.DefaultTestURL
|
||||
}
|
||||
delayData := &Delay{
|
||||
Name: params.ProxyName,
|
||||
Url: testUrl,
|
||||
Value: -1,
|
||||
}
|
||||
|
||||
expectedStatus, err := utils.NewUnsignedRanges[uint16]("")
|
||||
if err != nil {
|
||||
fn("")
|
||||
fn(delayData)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
@@ -228,48 +211,26 @@ func handleAsyncTestDelay(paramsString string, fn func(string)) {
|
||||
proxies := tunnel.AllProxies()
|
||||
proxy := proxies[params.ProxyName]
|
||||
|
||||
delayData := &Delay{
|
||||
Name: params.ProxyName,
|
||||
}
|
||||
|
||||
if proxy == nil {
|
||||
delayData.Value = -1
|
||||
data, _ := json.Marshal(delayData)
|
||||
fn(string(data))
|
||||
fn(delayData)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
testUrl := constant.DefaultTestURL
|
||||
|
||||
if params.TestUrl != "" {
|
||||
testUrl = params.TestUrl
|
||||
}
|
||||
delayData.Url = testUrl
|
||||
delay, err := proxy.URLTest(ctx, testUrl, expectedStatus)
|
||||
if err != nil || delay == 0 {
|
||||
delayData.Value = -1
|
||||
data, _ := json.Marshal(delayData)
|
||||
fn(string(data))
|
||||
fn(delayData)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
delayData.Value = int32(delay)
|
||||
data, _ := json.Marshal(delayData)
|
||||
fn(string(data))
|
||||
fn(delayData)
|
||||
return false, nil
|
||||
})
|
||||
}
|
||||
|
||||
func handleGetConnections() string {
|
||||
func handleGetConnections() *statistic.Snapshot {
|
||||
runLock.Lock()
|
||||
defer runLock.Unlock()
|
||||
snapshot := statistic.DefaultManager.Snapshot()
|
||||
data, err := json.Marshal(snapshot)
|
||||
if err != nil {
|
||||
logError("Error: %s", err)
|
||||
return ""
|
||||
}
|
||||
return string(data)
|
||||
return statistic.DefaultManager.Snapshot()
|
||||
}
|
||||
|
||||
func handleCloseConnections() bool {
|
||||
@@ -307,7 +268,7 @@ func handleCloseConnection(connectionId string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func handleGetExternalProviders() string {
|
||||
func handleGetExternalProviders() []ExternalProvider {
|
||||
runLock.Lock()
|
||||
defer runLock.Unlock()
|
||||
externalProviders = getExternalProvidersRaw()
|
||||
@@ -322,29 +283,21 @@ func handleGetExternalProviders() string {
|
||||
slices.SortFunc(eps, func(a, b ExternalProvider) int {
|
||||
return cmp.Compare(a.Name, b.Name)
|
||||
})
|
||||
data, err := json.Marshal(eps)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(data)
|
||||
return eps
|
||||
}
|
||||
|
||||
func handleGetExternalProvider(externalProviderName string) string {
|
||||
func handleGetExternalProvider(externalProviderName string) *ExternalProvider {
|
||||
runLock.Lock()
|
||||
defer runLock.Unlock()
|
||||
externalProvider, exist := externalProviders[externalProviderName]
|
||||
if !exist {
|
||||
return ""
|
||||
return nil
|
||||
}
|
||||
e, err := toExternalProvider(externalProvider)
|
||||
if err != nil {
|
||||
return ""
|
||||
return nil
|
||||
}
|
||||
data, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(data)
|
||||
return e
|
||||
}
|
||||
|
||||
func handleUpdateGeoData(geoType string) {
|
||||
@@ -449,9 +402,9 @@ func handleGetCountryCode(ip string, fn func(value string)) {
|
||||
}()
|
||||
}
|
||||
|
||||
func handleGetMemory(fn func(value string)) {
|
||||
func handleGetMemory(fn func(value uint64)) {
|
||||
go func() {
|
||||
fn(strconv.FormatUint(statistic.DefaultManager.Memory(), 10))
|
||||
fn(statistic.DefaultManager.Memory())
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -471,55 +424,44 @@ func handleCrash() {
|
||||
panic("handle invoke crash")
|
||||
}
|
||||
|
||||
func handleUpdateConfig(bytes []byte) string {
|
||||
var params = &UpdateParams{}
|
||||
err := json.Unmarshal(bytes, params)
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
func handleUpdateConfig(params *UpdateParams) string {
|
||||
updateConfig(params)
|
||||
return ""
|
||||
}
|
||||
|
||||
func handleDelFile(path string, result ActionResult) {
|
||||
func handleDeleteFile(path string, response MethodResponse) {
|
||||
go func() {
|
||||
fileInfo, err := os.Stat(path)
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
result.success(err.Error())
|
||||
response.success(err.Error())
|
||||
return
|
||||
}
|
||||
result.success("")
|
||||
response.success("")
|
||||
return
|
||||
}
|
||||
if fileInfo.IsDir() {
|
||||
err = os.RemoveAll(path)
|
||||
if err != nil {
|
||||
result.success(err.Error())
|
||||
response.success(err.Error())
|
||||
return
|
||||
}
|
||||
} else {
|
||||
err = os.Remove(path)
|
||||
if err != nil {
|
||||
result.success(err.Error())
|
||||
response.success(err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
result.success("")
|
||||
response.success("")
|
||||
}()
|
||||
}
|
||||
|
||||
func handleSetupConfig(bytes []byte) string {
|
||||
func handleSetupConfig(params *SetupParams) string {
|
||||
if !isInit {
|
||||
return "not initialized"
|
||||
}
|
||||
var params = defaultSetupParams()
|
||||
err := UnmarshalJson(bytes, params)
|
||||
if err != nil {
|
||||
logError("unmarshalRawConfig error %v", err)
|
||||
_ = applyConfig(defaultSetupParams())
|
||||
return err.Error()
|
||||
}
|
||||
err = applyConfig(params)
|
||||
err := applyConfig(params)
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
+50
-34
@@ -159,43 +159,44 @@ func handleUpdateDns(value string) {
|
||||
}()
|
||||
}
|
||||
|
||||
func (result ActionResult) send() {
|
||||
data, err := result.Json()
|
||||
func (response MethodResponse) send() {
|
||||
data, err := response.JSON()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
invokeResult(result.callback, string(data))
|
||||
if result.Method != messageMethod {
|
||||
releaseObject(result.callback)
|
||||
}
|
||||
invokeResult(response.callback, string(data))
|
||||
releaseObject(response.callback)
|
||||
}
|
||||
|
||||
func nextHandle(action *Action, result ActionResult) bool {
|
||||
switch action.Method {
|
||||
func handlePlatformMethodCall(call *MethodCall, response MethodResponse) bool {
|
||||
switch call.Method {
|
||||
case updateDnsMethod:
|
||||
data := action.Data.(string)
|
||||
handleUpdateDns(data)
|
||||
result.success(true)
|
||||
value := ""
|
||||
if !decodeMethodArguments(call, response, &value) {
|
||||
return true
|
||||
}
|
||||
handleUpdateDns(value)
|
||||
response.success(true)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
//export invokeAction
|
||||
func invokeAction(callback unsafe.Pointer, paramsChar *C.char) {
|
||||
//export invokeMethod
|
||||
func invokeMethod(callback unsafe.Pointer, paramsChar *C.char) {
|
||||
params := takeCString(paramsChar)
|
||||
var action = &Action{}
|
||||
err := json.Unmarshal([]byte(params), action)
|
||||
call := &MethodCall{}
|
||||
err := json.Unmarshal([]byte(params), call)
|
||||
if err != nil {
|
||||
invokeResult(callback, err.Error())
|
||||
response := MethodResponse{callback: callback}
|
||||
response.failure("invalid_method_call", err.Error(), nil)
|
||||
return
|
||||
}
|
||||
result := ActionResult{
|
||||
Id: action.Id,
|
||||
Method: action.Method,
|
||||
response := MethodResponse{
|
||||
ID: call.ID,
|
||||
callback: callback,
|
||||
}
|
||||
go handleAction(action, result)
|
||||
go handleMethodCall(call, response)
|
||||
}
|
||||
|
||||
//export startTUN
|
||||
@@ -212,14 +213,21 @@ func startTUN(callback unsafe.Pointer, fd C.int, stackChar, addressChar, dnsChar
|
||||
//export quickSetup
|
||||
func quickSetup(callback unsafe.Pointer, initParamsChar *C.char, setupParamsChar *C.char) {
|
||||
go func() {
|
||||
defer releaseObject(callback)
|
||||
initParamsString := takeCString(initParamsChar)
|
||||
setupParamsString := takeCString(setupParamsChar)
|
||||
if !handleInitClash(initParamsString) {
|
||||
initParams := InitParams{}
|
||||
if err := json.Unmarshal([]byte(initParamsString), &initParams); err != nil || !handleInitClash(&initParams) {
|
||||
invokeResult(callback, "init failed")
|
||||
return
|
||||
}
|
||||
isRunning = true
|
||||
message := handleSetupConfig([]byte(setupParamsString))
|
||||
setupParams := defaultSetupParams()
|
||||
if err := UnmarshalJson([]byte(setupParamsString), setupParams); err != nil {
|
||||
invokeResult(callback, err.Error())
|
||||
return
|
||||
}
|
||||
message := handleSetupConfig(setupParams)
|
||||
invokeResult(callback, message)
|
||||
}()
|
||||
}
|
||||
@@ -234,28 +242,36 @@ func setEventListener(listener unsafe.Pointer) {
|
||||
|
||||
//export getTotalTraffic
|
||||
func getTotalTraffic(onlyStatisticsProxy bool) *C.char {
|
||||
data := C.CString(handleGetTotalTraffic(onlyStatisticsProxy))
|
||||
defer C.free(unsafe.Pointer(data))
|
||||
return data
|
||||
return C.CString(marshalResult(handleGetTotalTraffic(onlyStatisticsProxy)))
|
||||
}
|
||||
|
||||
//export getTraffic
|
||||
func getTraffic(onlyStatisticsProxy bool) *C.char {
|
||||
data := C.CString(handleGetTraffic(onlyStatisticsProxy))
|
||||
defer C.free(unsafe.Pointer(data))
|
||||
return data
|
||||
return C.CString(marshalResult(handleGetTraffic(onlyStatisticsProxy)))
|
||||
}
|
||||
|
||||
func sendMessage(message Message) {
|
||||
func marshalResult(value any) string {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
logError("Result marshal error: %v", err)
|
||||
return ""
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func sendMessageBatch(messages []Message) {
|
||||
if eventListener == nil {
|
||||
return
|
||||
}
|
||||
result := ActionResult{
|
||||
Method: messageMethod,
|
||||
callback: eventListener,
|
||||
Data: message,
|
||||
call := MethodCall{
|
||||
Method: messageMethod,
|
||||
Arguments: mustMarshalJSON(messages),
|
||||
}
|
||||
result.send()
|
||||
data, err := json.Marshal(call)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
invokeResult(eventListener, string(data))
|
||||
}
|
||||
|
||||
//export stopTun
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package main
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
messageBatchInterval = 16 * time.Millisecond
|
||||
messageBatchSize = 32
|
||||
messageQueueSize = 256
|
||||
)
|
||||
|
||||
var messageQueue = make(chan Message, messageQueueSize)
|
||||
|
||||
func init() {
|
||||
go runMessageBatcher(messageQueue, sendMessageBatch)
|
||||
}
|
||||
|
||||
func sendMessage(message Message) {
|
||||
select {
|
||||
case messageQueue <- message:
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
// Event delivery must never block the core. Keep recent state by evicting
|
||||
// the oldest queued event when producers outrun the UI.
|
||||
select {
|
||||
case <-messageQueue:
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case messageQueue <- message:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func runMessageBatcher(messages <-chan Message, send func([]Message)) {
|
||||
ticker := time.NewTicker(messageBatchInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
batch := make([]Message, 0, messageBatchSize)
|
||||
flush := func() {
|
||||
if len(batch) == 0 {
|
||||
return
|
||||
}
|
||||
current := append([]Message(nil), batch...)
|
||||
batch = batch[:0]
|
||||
send(current)
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case message, ok := <-messages:
|
||||
if !ok {
|
||||
flush()
|
||||
return
|
||||
}
|
||||
batch = append(batch, message)
|
||||
if len(batch) >= messageBatchSize {
|
||||
flush()
|
||||
}
|
||||
case <-ticker.C:
|
||||
flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
+284
@@ -0,0 +1,284 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
type MethodCall struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Method CoreMethod `json:"method"`
|
||||
Arguments json.RawMessage `json:"arguments"`
|
||||
}
|
||||
|
||||
func (call MethodCall) decodeArguments(target any) error {
|
||||
if len(call.Arguments) == 0 || string(call.Arguments) == "null" {
|
||||
return fmt.Errorf("missing arguments")
|
||||
}
|
||||
return json.Unmarshal(call.Arguments, target)
|
||||
}
|
||||
|
||||
func decodeMethodArguments(call *MethodCall, response MethodResponse, target any) bool {
|
||||
if err := call.decodeArguments(target); err != nil {
|
||||
response.failure(
|
||||
"invalid_arguments",
|
||||
fmt.Sprintf("invalid arguments for %s: %v", call.Method, err),
|
||||
nil,
|
||||
)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type MethodError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details any `json:"details"`
|
||||
}
|
||||
|
||||
type MethodResponse struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Result any `json:"result"`
|
||||
Error *MethodError `json:"error,omitempty"`
|
||||
callback unsafe.Pointer
|
||||
}
|
||||
|
||||
func mustMarshalJSON(value any) json.RawMessage {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func (response MethodResponse) JSON() ([]byte, error) {
|
||||
return json.Marshal(response)
|
||||
}
|
||||
|
||||
func (response MethodResponse) success(result any) {
|
||||
response.Result = result
|
||||
response.Error = nil
|
||||
response.send()
|
||||
}
|
||||
|
||||
func (response MethodResponse) failure(code, message string, details any) {
|
||||
response.Result = nil
|
||||
response.Error = &MethodError{
|
||||
Code: code,
|
||||
Message: message,
|
||||
Details: details,
|
||||
}
|
||||
response.send()
|
||||
}
|
||||
|
||||
func (response MethodResponse) notImplemented(method CoreMethod) {
|
||||
response.failure(
|
||||
"not_implemented",
|
||||
fmt.Sprintf("unknown method: %s", method),
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
func handleMethodCall(call *MethodCall, response MethodResponse) {
|
||||
// The crash method is a developer-only fatal-path test. It must bypass the
|
||||
// recovery below so the core process terminates; on Android this also
|
||||
// terminates the in-process application.
|
||||
if call.Method == crashMethod {
|
||||
handleCrash()
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
buf := make([]byte, 4096)
|
||||
n := runtime.Stack(buf, false)
|
||||
logError("panic in handleMethodCall(%s): %v\n%s", call.Method, r, buf[:n])
|
||||
response.failure("internal_error", fmt.Sprintf("internal panic: %v", r), nil)
|
||||
}
|
||||
}()
|
||||
switch call.Method {
|
||||
case initClashMethod:
|
||||
params := InitParams{}
|
||||
if !decodeMethodArguments(call, response, ¶ms) {
|
||||
return
|
||||
}
|
||||
response.success(handleInitClash(¶ms))
|
||||
return
|
||||
case getIsInitMethod:
|
||||
response.success(handleGetIsInit())
|
||||
return
|
||||
case forceGcMethod:
|
||||
handleForceGC()
|
||||
response.success(true)
|
||||
return
|
||||
case shutdownMethod:
|
||||
response.success(handleShutdown())
|
||||
return
|
||||
case validateConfigMethod:
|
||||
path := ""
|
||||
if !decodeMethodArguments(call, response, &path) {
|
||||
return
|
||||
}
|
||||
response.success(handleValidateConfig(path))
|
||||
return
|
||||
case updateConfigMethod:
|
||||
params := UpdateParams{}
|
||||
if !decodeMethodArguments(call, response, ¶ms) {
|
||||
return
|
||||
}
|
||||
response.success(handleUpdateConfig(¶ms))
|
||||
return
|
||||
case setupConfigMethod:
|
||||
params := defaultSetupParams()
|
||||
if !decodeMethodArguments(call, response, params) {
|
||||
return
|
||||
}
|
||||
response.success(handleSetupConfig(params))
|
||||
return
|
||||
case getProxiesMethod:
|
||||
response.success(handleGetProxies())
|
||||
return
|
||||
case changeProxyMethod:
|
||||
params := ChangeProxyParams{}
|
||||
if !decodeMethodArguments(call, response, ¶ms) {
|
||||
return
|
||||
}
|
||||
handleChangeProxy(¶ms, func(value string) {
|
||||
response.success(value)
|
||||
})
|
||||
return
|
||||
case getTrafficMethod:
|
||||
onlyStatisticsProxy := false
|
||||
if !decodeMethodArguments(call, response, &onlyStatisticsProxy) {
|
||||
return
|
||||
}
|
||||
response.success(handleGetTraffic(onlyStatisticsProxy))
|
||||
return
|
||||
case getTotalTrafficMethod:
|
||||
onlyStatisticsProxy := false
|
||||
if !decodeMethodArguments(call, response, &onlyStatisticsProxy) {
|
||||
return
|
||||
}
|
||||
response.success(handleGetTotalTraffic(onlyStatisticsProxy))
|
||||
return
|
||||
case resetTrafficMethod:
|
||||
handleResetTraffic()
|
||||
response.success(true)
|
||||
return
|
||||
case asyncTestDelayMethod:
|
||||
params := TestDelayParams{}
|
||||
if !decodeMethodArguments(call, response, ¶ms) {
|
||||
return
|
||||
}
|
||||
handleAsyncTestDelay(¶ms, func(value *Delay) {
|
||||
response.success(value)
|
||||
})
|
||||
return
|
||||
case getConnectionsMethod:
|
||||
response.success(handleGetConnections())
|
||||
return
|
||||
case closeConnectionsMethod:
|
||||
response.success(handleCloseConnections())
|
||||
return
|
||||
case resetConnectionsMethod:
|
||||
response.success(handleResetConnections())
|
||||
return
|
||||
case getConfigMethod:
|
||||
path := ""
|
||||
if !decodeMethodArguments(call, response, &path) {
|
||||
return
|
||||
}
|
||||
config, err := handleGetConfig(path)
|
||||
if err != nil {
|
||||
response.failure("core_error", err.Error(), nil)
|
||||
return
|
||||
}
|
||||
response.success(config)
|
||||
return
|
||||
case closeConnectionMethod:
|
||||
id := ""
|
||||
if !decodeMethodArguments(call, response, &id) {
|
||||
return
|
||||
}
|
||||
response.success(handleCloseConnection(id))
|
||||
return
|
||||
case getExternalProvidersMethod:
|
||||
response.success(handleGetExternalProviders())
|
||||
return
|
||||
case getExternalProviderMethod:
|
||||
externalProviderName := ""
|
||||
if !decodeMethodArguments(call, response, &externalProviderName) {
|
||||
return
|
||||
}
|
||||
response.success(handleGetExternalProvider(externalProviderName))
|
||||
return
|
||||
case updateGeoDataMethod:
|
||||
geoType := ""
|
||||
if !decodeMethodArguments(call, response, &geoType) {
|
||||
return
|
||||
}
|
||||
handleUpdateGeoData(geoType)
|
||||
response.success("")
|
||||
return
|
||||
case updateExternalProviderMethod:
|
||||
providerName := ""
|
||||
if !decodeMethodArguments(call, response, &providerName) {
|
||||
return
|
||||
}
|
||||
handleUpdateExternalProvider(providerName, func(value string) {
|
||||
response.success(value)
|
||||
})
|
||||
return
|
||||
case sideLoadExternalProviderMethod:
|
||||
params := map[string]string{}
|
||||
if !decodeMethodArguments(call, response, ¶ms) {
|
||||
return
|
||||
}
|
||||
providerName := params["providerName"]
|
||||
data := params["data"]
|
||||
handleSideLoadExternalProvider(providerName, []byte(data), func(value string) {
|
||||
response.success(value)
|
||||
})
|
||||
return
|
||||
case startLogMethod:
|
||||
handleStartLog()
|
||||
response.success(true)
|
||||
return
|
||||
case stopLogMethod:
|
||||
handleStopLog()
|
||||
response.success(true)
|
||||
return
|
||||
case startListenerMethod:
|
||||
response.success(handleStartListener())
|
||||
return
|
||||
case stopListenerMethod:
|
||||
response.success(handleStopListener())
|
||||
return
|
||||
case getCountryCodeMethod:
|
||||
ip := ""
|
||||
if !decodeMethodArguments(call, response, &ip) {
|
||||
return
|
||||
}
|
||||
handleGetCountryCode(ip, func(value string) {
|
||||
response.success(value)
|
||||
})
|
||||
return
|
||||
case getMemoryMethod:
|
||||
handleGetMemory(func(value uint64) {
|
||||
response.success(value)
|
||||
})
|
||||
return
|
||||
case deleteFileMethod:
|
||||
path := ""
|
||||
if !decodeMethodArguments(call, response, &path) {
|
||||
return
|
||||
}
|
||||
handleDeleteFile(path, response)
|
||||
return
|
||||
default:
|
||||
if !handlePlatformMethodCall(call, response) {
|
||||
response.notImplemented(call.Method)
|
||||
}
|
||||
}
|
||||
}
|
||||
+49
-21
@@ -5,6 +5,7 @@ package main
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
)
|
||||
@@ -14,29 +15,54 @@ var (
|
||||
connMu sync.Mutex
|
||||
)
|
||||
|
||||
func (result ActionResult) send() {
|
||||
data, err := result.Json()
|
||||
const maxIPCFrameSize = 64 * 1024 * 1024
|
||||
|
||||
func (response MethodResponse) send() {
|
||||
data, err := response.JSON()
|
||||
if err != nil {
|
||||
logError("ActionResult marshal error: method=%s id=%s err=%v", result.Method, result.Id, err)
|
||||
logError("MethodResponse marshal error: id=%s err=%v", response.ID, err)
|
||||
return
|
||||
}
|
||||
send(data)
|
||||
}
|
||||
|
||||
func sendMessage(message Message) {
|
||||
result := ActionResult{
|
||||
Method: messageMethod,
|
||||
Data: message,
|
||||
func sendMessageBatch(messages []Message) {
|
||||
call := MethodCall{
|
||||
Method: messageMethod,
|
||||
Arguments: mustMarshalJSON(messages),
|
||||
}
|
||||
result.send()
|
||||
data, err := json.Marshal(call)
|
||||
if err != nil {
|
||||
logError("MethodCall marshal error: method=%s err=%v", call.Method, err)
|
||||
return
|
||||
}
|
||||
send(data)
|
||||
}
|
||||
|
||||
func writeFrame(w io.Writer, data []byte) error {
|
||||
frame := make([]byte, 4+len(data))
|
||||
binary.LittleEndian.PutUint32(frame, uint32(len(data)))
|
||||
copy(frame[4:], data)
|
||||
_, err := w.Write(frame)
|
||||
return err
|
||||
if len(data) > maxIPCFrameSize {
|
||||
return fmt.Errorf("IPC frame exceeds %d bytes", maxIPCFrameSize)
|
||||
}
|
||||
lenBuf := [4]byte{}
|
||||
binary.LittleEndian.PutUint32(lenBuf[:], uint32(len(data)))
|
||||
if err := writeAll(w, lenBuf[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeAll(w, data)
|
||||
}
|
||||
|
||||
func writeAll(w io.Writer, data []byte) error {
|
||||
for len(data) > 0 {
|
||||
n, err := w.Write(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return io.ErrShortWrite
|
||||
}
|
||||
data = data[n:]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readFrame(r io.Reader) ([]byte, error) {
|
||||
@@ -45,7 +71,10 @@ func readFrame(r io.Reader) ([]byte, error) {
|
||||
return nil, err
|
||||
}
|
||||
length := binary.LittleEndian.Uint32(lenBuf)
|
||||
data := make([]byte, length)
|
||||
if length > maxIPCFrameSize {
|
||||
return nil, fmt.Errorf("IPC frame exceeds %d bytes", maxIPCFrameSize)
|
||||
}
|
||||
data := make([]byte, int(length))
|
||||
if _, err := io.ReadFull(r, data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -83,24 +112,23 @@ func startServer(arg string) {
|
||||
}
|
||||
return
|
||||
}
|
||||
var action = &Action{}
|
||||
call := &MethodCall{}
|
||||
|
||||
err = json.Unmarshal(data, action)
|
||||
err = json.Unmarshal(data, call)
|
||||
|
||||
if err != nil {
|
||||
logError("server unmarshal error: %v (data: %q)", err, data)
|
||||
continue
|
||||
}
|
||||
|
||||
result := ActionResult{
|
||||
Id: action.Id,
|
||||
Method: action.Method,
|
||||
response := MethodResponse{
|
||||
ID: call.ID,
|
||||
}
|
||||
|
||||
go handleAction(action, result)
|
||||
go handleMethodCall(call, response)
|
||||
}
|
||||
}
|
||||
|
||||
func nextHandle(action *Action, result ActionResult) bool {
|
||||
func handlePlatformMethodCall(call *MethodCall, response MethodResponse) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
+10
-10
@@ -4,43 +4,43 @@ import 'package:flutter/material.dart';
|
||||
|
||||
extension ColorExtension on Color {
|
||||
Color get opacity80 {
|
||||
return withAlpha(204);
|
||||
return withValues(alpha: 0.8);
|
||||
}
|
||||
|
||||
Color get opacity60 {
|
||||
return withAlpha(153);
|
||||
return withValues(alpha: 0.6);
|
||||
}
|
||||
|
||||
Color get opacity50 {
|
||||
return withAlpha(128);
|
||||
return withValues(alpha: 0.5);
|
||||
}
|
||||
|
||||
Color get opacity38 {
|
||||
return withAlpha(97);
|
||||
return withValues(alpha: 0.38);
|
||||
}
|
||||
|
||||
Color get opacity30 {
|
||||
return withAlpha(77);
|
||||
return withValues(alpha: 0.3);
|
||||
}
|
||||
|
||||
Color get opacity12 {
|
||||
return withAlpha(31);
|
||||
return withValues(alpha: 0.12);
|
||||
}
|
||||
|
||||
Color get opacity15 {
|
||||
return withAlpha(38);
|
||||
return withValues(alpha: 0.15);
|
||||
}
|
||||
|
||||
Color get opacity10 {
|
||||
return withAlpha(15);
|
||||
return withValues(alpha: 0.1);
|
||||
}
|
||||
|
||||
Color get opacity3 {
|
||||
return withAlpha(76);
|
||||
return withValues(alpha: 0.03);
|
||||
}
|
||||
|
||||
Color get opacity0 {
|
||||
return withAlpha(0);
|
||||
return withValues(alpha: 0);
|
||||
}
|
||||
|
||||
int get value32bit {
|
||||
|
||||
@@ -41,6 +41,11 @@ class System {
|
||||
};
|
||||
}
|
||||
|
||||
Future<bool> didCrashOnPreviousExecution() async {
|
||||
if (!isAndroid) return false;
|
||||
return await app?.didCrashOnPreviousExecution() ?? false;
|
||||
}
|
||||
|
||||
Future<bool> checkIsAdmin() async {
|
||||
final corePath = appPath.corePath.replaceAll(' ', '\\\\ ');
|
||||
if (system.isWindows) {
|
||||
|
||||
+13
-49
@@ -1,5 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
@@ -135,10 +134,7 @@ class CoreController {
|
||||
}
|
||||
|
||||
Future<List<TrackerInfo>> getConnections() async {
|
||||
final res = await _interface.getConnections();
|
||||
final connectionsData = json.decode(res) as Map;
|
||||
final connectionsRaw = connectionsData['connections'] as List? ?? [];
|
||||
return connectionsRaw.map((e) => TrackerInfo.fromJson(e)).toList();
|
||||
return _interface.getConnections();
|
||||
}
|
||||
|
||||
Future<void> closeConnection(String id) async {
|
||||
@@ -154,27 +150,13 @@ class CoreController {
|
||||
}
|
||||
|
||||
Future<List<ExternalProvider>> getExternalProviders() async {
|
||||
final externalProvidersRawString = await _interface.getExternalProviders();
|
||||
if (externalProvidersRawString.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
final externalProviders =
|
||||
(await externalProvidersRawString.commonToJSON<List<dynamic>>())
|
||||
.map((item) => ExternalProvider.fromJson(item))
|
||||
.toList();
|
||||
return externalProviders;
|
||||
return _interface.getExternalProviders();
|
||||
}
|
||||
|
||||
Future<ExternalProvider?> getExternalProvider(
|
||||
String externalProviderName,
|
||||
) async {
|
||||
final externalProvidersRawString = await _interface.getExternalProvider(
|
||||
externalProviderName,
|
||||
);
|
||||
if (externalProvidersRawString.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return ExternalProvider.fromJson(json.decode(externalProvidersRawString));
|
||||
return _interface.getExternalProvider(externalProviderName);
|
||||
}
|
||||
|
||||
Future<String> updateGeoData(String type) {
|
||||
@@ -204,29 +186,21 @@ class CoreController {
|
||||
}
|
||||
|
||||
Future<Delay> getDelay(String url, String proxyName) async {
|
||||
final data = await _interface.asyncTestDelay(url, proxyName);
|
||||
return Delay.fromJson(json.decode(data));
|
||||
return _interface.asyncTestDelay(url, proxyName);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> getConfig(int id) async {
|
||||
final profilePath = await appPath.getProfilePath(id.toString());
|
||||
final res = await _interface.getConfig(profilePath);
|
||||
if (res.isSuccess) {
|
||||
final data = Map<String, dynamic>.from(res.data);
|
||||
data['rules'] = data['rule'];
|
||||
data.remove('rule');
|
||||
return data;
|
||||
} else {
|
||||
throw res.message;
|
||||
}
|
||||
final data = Map<String, dynamic>.from(
|
||||
await _interface.getConfig(profilePath),
|
||||
);
|
||||
data['rules'] = data['rule'];
|
||||
data.remove('rule');
|
||||
return data;
|
||||
}
|
||||
|
||||
Future<Traffic> getTraffic(bool onlyStatisticsProxy) async {
|
||||
final trafficString = await _interface.getTraffic(onlyStatisticsProxy);
|
||||
if (trafficString.isEmpty) {
|
||||
return const Traffic();
|
||||
}
|
||||
return Traffic.fromJson(json.decode(trafficString));
|
||||
return _interface.getTraffic(onlyStatisticsProxy);
|
||||
}
|
||||
|
||||
Future<IpInfo?> getCountryCode(String ip) async {
|
||||
@@ -238,21 +212,11 @@ class CoreController {
|
||||
}
|
||||
|
||||
Future<Traffic> getTotalTraffic(bool onlyStatisticsProxy) async {
|
||||
final totalTrafficString = await _interface.getTotalTraffic(
|
||||
onlyStatisticsProxy,
|
||||
);
|
||||
if (totalTrafficString.isEmpty) {
|
||||
return const Traffic();
|
||||
}
|
||||
return Traffic.fromJson(json.decode(totalTrafficString));
|
||||
return _interface.getTotalTraffic(onlyStatisticsProxy);
|
||||
}
|
||||
|
||||
Future<int> getMemory() async {
|
||||
final value = await _interface.getMemory();
|
||||
if (value.isEmpty) {
|
||||
return 0;
|
||||
}
|
||||
return int.parse(value);
|
||||
return _interface.getMemory();
|
||||
}
|
||||
|
||||
void resetTraffic() {
|
||||
|
||||
@@ -2,4 +2,5 @@ export 'controller.dart';
|
||||
export 'core.dart';
|
||||
export 'event.dart';
|
||||
export 'lib.dart';
|
||||
export 'method.dart';
|
||||
export 'service.dart';
|
||||
|
||||
@@ -4,6 +4,14 @@ import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
List<CoreEvent> coreEventsFromData(Object? data) {
|
||||
final items = data is List ? data : [data];
|
||||
return items
|
||||
.whereType<Map>()
|
||||
.map((item) => CoreEvent.fromJson(Map<String, Object?>.from(item)))
|
||||
.toList();
|
||||
}
|
||||
|
||||
abstract mixin class CoreEventListener {
|
||||
void onLog(Log log) {}
|
||||
|
||||
|
||||
+148
-105
@@ -1,10 +1,11 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/models/models.dart';
|
||||
|
||||
import 'method.dart';
|
||||
|
||||
mixin CoreInterface {
|
||||
Future<bool> init(InitParams params);
|
||||
|
||||
@@ -18,9 +19,9 @@ mixin CoreInterface {
|
||||
|
||||
Future<String> validateConfig(String path);
|
||||
|
||||
Future<Result> getConfig(String path);
|
||||
Future<Map<String, dynamic>> getConfig(String path);
|
||||
|
||||
Future<String> asyncTestDelay(String url, String proxyName);
|
||||
Future<Delay> asyncTestDelay(String url, String proxyName);
|
||||
|
||||
Future<String> updateConfig(UpdateParams updateParams);
|
||||
|
||||
@@ -34,9 +35,9 @@ mixin CoreInterface {
|
||||
|
||||
Future<bool> stopListener();
|
||||
|
||||
Future<String> getExternalProviders();
|
||||
Future<List<ExternalProvider>> getExternalProviders();
|
||||
|
||||
Future<String>? getExternalProvider(String externalProviderName);
|
||||
Future<ExternalProvider?> getExternalProvider(String externalProviderName);
|
||||
|
||||
Future<String> updateGeoData(String type);
|
||||
|
||||
@@ -47,13 +48,13 @@ mixin CoreInterface {
|
||||
|
||||
Future<String> updateExternalProvider(String providerName);
|
||||
|
||||
FutureOr<String> getTraffic(bool onlyStatisticsProxy);
|
||||
FutureOr<Traffic> getTraffic(bool onlyStatisticsProxy);
|
||||
|
||||
FutureOr<String> getTotalTraffic(bool onlyStatisticsProxy);
|
||||
FutureOr<Traffic> getTotalTraffic(bool onlyStatisticsProxy);
|
||||
|
||||
FutureOr<String> getCountryCode(String ip);
|
||||
|
||||
FutureOr<String> getMemory();
|
||||
FutureOr<int> getMemory();
|
||||
|
||||
FutureOr<void> resetTraffic();
|
||||
|
||||
@@ -63,7 +64,7 @@ mixin CoreInterface {
|
||||
|
||||
Future<bool> crash();
|
||||
|
||||
FutureOr<String> getConnections();
|
||||
FutureOr<List<TrackerInfo>> getConnections();
|
||||
|
||||
FutureOr<bool> closeConnection(String id);
|
||||
|
||||
@@ -75,55 +76,60 @@ mixin CoreInterface {
|
||||
}
|
||||
|
||||
abstract class CoreHandlerInterface with CoreInterface {
|
||||
int _methodCallId = 0;
|
||||
|
||||
String get nextMethodCallId => '${++_methodCallId}';
|
||||
|
||||
Completer get completer;
|
||||
|
||||
FutureOr<bool> destroy();
|
||||
|
||||
Future<T?> _invoke<T>({
|
||||
required ActionMethod method,
|
||||
dynamic data,
|
||||
Future<T?> _invokeMethod<T>({
|
||||
required CoreMethod method,
|
||||
Object? arguments,
|
||||
Duration? timeout,
|
||||
}) async {
|
||||
try {
|
||||
await completer.future.timeout(const Duration(seconds: 10));
|
||||
} catch (e) {
|
||||
commonPrint.log(
|
||||
'Invoke pre ${method.name} timeout $e',
|
||||
'Invoke method ${method.name} before connection timed out: $e',
|
||||
logLevel: LogLevel.error,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return await utils.handleWatch(
|
||||
onStart: () {
|
||||
commonPrint.log('Invoke ${method.name} ${DateTime.now()} $data');
|
||||
commonPrint.log(
|
||||
'Invoke method ${method.name} ${DateTime.now()} $arguments',
|
||||
);
|
||||
},
|
||||
function: () async {
|
||||
return invoke<T>(method: method, data: data, timeout: timeout);
|
||||
return invokeMethod<T>(
|
||||
method: method,
|
||||
arguments: arguments,
|
||||
timeout: timeout,
|
||||
);
|
||||
},
|
||||
onEnd: (data, elapsedMilliseconds) {
|
||||
commonPrint.log('Invoke ${method.name} ${elapsedMilliseconds}ms');
|
||||
onEnd: (result, elapsedMilliseconds) {
|
||||
commonPrint.log(
|
||||
'Invoke method ${method.name} completed in ${elapsedMilliseconds}ms',
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<T?> invoke<T>({
|
||||
required ActionMethod method,
|
||||
dynamic data,
|
||||
Future<T?> invokeMethod<T>({
|
||||
required CoreMethod method,
|
||||
Object? arguments,
|
||||
Duration? timeout,
|
||||
});
|
||||
|
||||
Future<T> parasResult<T>(ActionResult result) async {
|
||||
return switch (result.method) {
|
||||
ActionMethod.getConfig => result.toResult as T,
|
||||
_ => result.data as T,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> init(InitParams params) async {
|
||||
return await _invoke<bool>(
|
||||
method: ActionMethod.initClash,
|
||||
data: json.encode(params),
|
||||
return await _invokeMethod<bool>(
|
||||
method: CoreMethod.initClash,
|
||||
arguments: params.toJson(),
|
||||
) ??
|
||||
false;
|
||||
}
|
||||
@@ -133,56 +139,65 @@ abstract class CoreHandlerInterface with CoreInterface {
|
||||
|
||||
@override
|
||||
Future<bool> get isInit async {
|
||||
return await _invoke<bool>(method: ActionMethod.getIsInit) ?? false;
|
||||
return await _invokeMethod<bool>(method: CoreMethod.getIsInit) ?? false;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> forceGc() async {
|
||||
return await _invoke<bool>(method: ActionMethod.forceGc) ?? false;
|
||||
return await _invokeMethod<bool>(method: CoreMethod.forceGc) ?? false;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> validateConfig(String path) async {
|
||||
return await _invoke<String>(
|
||||
method: ActionMethod.validateConfig,
|
||||
data: path,
|
||||
return await _invokeMethod<String>(
|
||||
method: CoreMethod.validateConfig,
|
||||
arguments: path,
|
||||
) ??
|
||||
'';
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> updateConfig(UpdateParams updateParams) async {
|
||||
return await _invoke<String>(
|
||||
method: ActionMethod.updateConfig,
|
||||
data: json.encode(updateParams),
|
||||
return await _invokeMethod<String>(
|
||||
method: CoreMethod.updateConfig,
|
||||
arguments: updateParams.toJson(),
|
||||
) ??
|
||||
'';
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result> getConfig(String path) async {
|
||||
final res = await _invoke(method: ActionMethod.getConfig, data: path);
|
||||
return res ?? Result.success({});
|
||||
Future<Map<String, dynamic>> getConfig(String path) async {
|
||||
final result = await _invokeMethod<Map<String, dynamic>>(
|
||||
method: CoreMethod.getConfig,
|
||||
arguments: path,
|
||||
);
|
||||
if (result == null) {
|
||||
throw const CoreMethodException(
|
||||
code: 'empty_result',
|
||||
message: 'Core returned an empty config result',
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> setupConfig(SetupParams setupParams) async {
|
||||
return await _invoke<String>(
|
||||
method: ActionMethod.setupConfig,
|
||||
data: json.encode(setupParams),
|
||||
return await _invokeMethod<String>(
|
||||
method: CoreMethod.setupConfig,
|
||||
arguments: setupParams.toJson(),
|
||||
) ??
|
||||
'';
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> crash() async {
|
||||
return await _invoke<bool>(method: ActionMethod.crash) ?? false;
|
||||
return await _invokeMethod<bool>(method: CoreMethod.crash) ?? false;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ProxiesData> getProxies() async {
|
||||
final data = await _invoke<Map<String, dynamic>>(
|
||||
method: ActionMethod.getProxies,
|
||||
final data = await _invokeMethod<Map<String, dynamic>>(
|
||||
method: CoreMethod.getProxies,
|
||||
);
|
||||
return data != null
|
||||
? ProxiesData.fromJson(data)
|
||||
@@ -191,33 +206,44 @@ abstract class CoreHandlerInterface with CoreInterface {
|
||||
|
||||
@override
|
||||
Future<String> changeProxy(ChangeProxyParams changeProxyParams) async {
|
||||
return await _invoke<String>(
|
||||
method: ActionMethod.changeProxy,
|
||||
data: json.encode(changeProxyParams),
|
||||
return await _invokeMethod<String>(
|
||||
method: CoreMethod.changeProxy,
|
||||
arguments: changeProxyParams.toJson(),
|
||||
) ??
|
||||
'';
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> getExternalProviders() async {
|
||||
return await _invoke<String>(method: ActionMethod.getExternalProviders) ??
|
||||
'';
|
||||
Future<List<ExternalProvider>> getExternalProviders() async {
|
||||
final data = await _invokeMethod<List<dynamic>>(
|
||||
method: CoreMethod.getExternalProviders,
|
||||
);
|
||||
return data
|
||||
?.whereType<Map>()
|
||||
.map(
|
||||
(item) =>
|
||||
ExternalProvider.fromJson(Map<String, Object?>.from(item)),
|
||||
)
|
||||
.toList() ??
|
||||
[];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> getExternalProvider(String externalProviderName) async {
|
||||
return await _invoke<String>(
|
||||
method: ActionMethod.getExternalProvider,
|
||||
data: externalProviderName,
|
||||
) ??
|
||||
'';
|
||||
Future<ExternalProvider?> getExternalProvider(
|
||||
String externalProviderName,
|
||||
) async {
|
||||
final data = await _invokeMethod<Map<String, dynamic>>(
|
||||
method: CoreMethod.getExternalProvider,
|
||||
arguments: externalProviderName,
|
||||
);
|
||||
return data == null ? null : ExternalProvider.fromJson(data);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> updateGeoData(String type) async {
|
||||
return await _invoke<String>(
|
||||
method: ActionMethod.updateGeoData,
|
||||
data: type,
|
||||
return await _invokeMethod<String>(
|
||||
method: CoreMethod.updateGeoData,
|
||||
arguments: type,
|
||||
) ??
|
||||
'';
|
||||
}
|
||||
@@ -227,121 +253,138 @@ abstract class CoreHandlerInterface with CoreInterface {
|
||||
required String providerName,
|
||||
required String data,
|
||||
}) async {
|
||||
return await _invoke<String>(
|
||||
method: ActionMethod.sideLoadExternalProvider,
|
||||
data: json.encode({'providerName': providerName, 'data': data}),
|
||||
return await _invokeMethod<String>(
|
||||
method: CoreMethod.sideLoadExternalProvider,
|
||||
arguments: {'providerName': providerName, 'data': data},
|
||||
) ??
|
||||
'';
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> updateExternalProvider(String providerName) async {
|
||||
return await _invoke<String>(
|
||||
method: ActionMethod.updateExternalProvider,
|
||||
data: providerName,
|
||||
return await _invokeMethod<String>(
|
||||
method: CoreMethod.updateExternalProvider,
|
||||
arguments: providerName,
|
||||
) ??
|
||||
'';
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> getConnections() async {
|
||||
return await _invoke<String>(method: ActionMethod.getConnections) ?? '';
|
||||
Future<List<TrackerInfo>> getConnections() async {
|
||||
final data = await _invokeMethod<Map<String, dynamic>>(
|
||||
method: CoreMethod.getConnections,
|
||||
);
|
||||
final connections = data?['connections'];
|
||||
if (connections is! List) {
|
||||
return [];
|
||||
}
|
||||
return connections
|
||||
.whereType<Map>()
|
||||
.map((item) => TrackerInfo.fromJson(Map<String, Object?>.from(item)))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> closeConnections() async {
|
||||
return await _invoke<bool>(method: ActionMethod.closeConnections) ?? false;
|
||||
return await _invokeMethod<bool>(method: CoreMethod.closeConnections) ??
|
||||
false;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> resetConnections() async {
|
||||
return await _invoke<bool>(method: ActionMethod.resetConnections) ?? false;
|
||||
return await _invokeMethod<bool>(method: CoreMethod.resetConnections) ??
|
||||
false;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> closeConnection(String id) async {
|
||||
return await _invoke<bool>(
|
||||
method: ActionMethod.closeConnection,
|
||||
data: id,
|
||||
return await _invokeMethod<bool>(
|
||||
method: CoreMethod.closeConnection,
|
||||
arguments: id,
|
||||
) ??
|
||||
false;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> getTotalTraffic(bool onlyStatisticsProxy) async {
|
||||
return await _invoke<String>(
|
||||
method: ActionMethod.getTotalTraffic,
|
||||
data: onlyStatisticsProxy,
|
||||
) ??
|
||||
'';
|
||||
Future<Traffic> getTotalTraffic(bool onlyStatisticsProxy) async {
|
||||
final data = await _invokeMethod<Map<String, dynamic>>(
|
||||
method: CoreMethod.getTotalTraffic,
|
||||
arguments: onlyStatisticsProxy,
|
||||
);
|
||||
return data == null ? const Traffic() : Traffic.fromJson(data);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> getTraffic(bool onlyStatisticsProxy) async {
|
||||
return await _invoke<String>(
|
||||
method: ActionMethod.getTraffic,
|
||||
data: onlyStatisticsProxy,
|
||||
) ??
|
||||
'';
|
||||
Future<Traffic> getTraffic(bool onlyStatisticsProxy) async {
|
||||
final data = await _invokeMethod<Map<String, dynamic>>(
|
||||
method: CoreMethod.getTraffic,
|
||||
arguments: onlyStatisticsProxy,
|
||||
);
|
||||
return data == null ? const Traffic() : Traffic.fromJson(data);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> deleteFile(String path) async {
|
||||
return await _invoke<String>(method: ActionMethod.deleteFile, data: path) ??
|
||||
return await _invokeMethod<String>(
|
||||
method: CoreMethod.deleteFile,
|
||||
arguments: path,
|
||||
) ??
|
||||
'';
|
||||
}
|
||||
|
||||
@override
|
||||
FutureOr<void> resetTraffic() {
|
||||
_invoke(method: ActionMethod.resetTraffic);
|
||||
_invokeMethod(method: CoreMethod.resetTraffic);
|
||||
}
|
||||
|
||||
@override
|
||||
FutureOr<void> startLog() {
|
||||
_invoke(method: ActionMethod.startLog);
|
||||
_invokeMethod(method: CoreMethod.startLog);
|
||||
}
|
||||
|
||||
@override
|
||||
FutureOr<void> stopLog() {
|
||||
_invoke<bool>(method: ActionMethod.stopLog);
|
||||
_invokeMethod<bool>(method: CoreMethod.stopLog);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> startListener() async {
|
||||
return await _invoke<bool>(method: ActionMethod.startListener) ?? false;
|
||||
return await _invokeMethod<bool>(method: CoreMethod.startListener) ?? false;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> stopListener() async {
|
||||
return await _invoke<bool>(method: ActionMethod.stopListener) ?? false;
|
||||
return await _invokeMethod<bool>(method: CoreMethod.stopListener) ?? false;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> asyncTestDelay(String url, String proxyName) async {
|
||||
Future<Delay> asyncTestDelay(String url, String proxyName) async {
|
||||
final delayParams = {
|
||||
'proxy-name': proxyName,
|
||||
'timeout': httpTimeoutDuration.inMilliseconds,
|
||||
'test-url': url,
|
||||
};
|
||||
return await _invoke<String>(
|
||||
method: ActionMethod.asyncTestDelay,
|
||||
data: json.encode(delayParams),
|
||||
timeout: const Duration(seconds: 6),
|
||||
) ??
|
||||
json.encode(Delay(name: proxyName, value: -1, url: url));
|
||||
final data = await _invokeMethod<Map<String, dynamic>>(
|
||||
method: CoreMethod.asyncTestDelay,
|
||||
arguments: delayParams,
|
||||
timeout: const Duration(seconds: 6),
|
||||
);
|
||||
return data == null
|
||||
? Delay(name: proxyName, value: -1, url: url)
|
||||
: Delay.fromJson(data);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> getCountryCode(String ip) async {
|
||||
return await _invoke<String>(
|
||||
method: ActionMethod.getCountryCode,
|
||||
data: ip,
|
||||
return await _invokeMethod<String>(
|
||||
method: CoreMethod.getCountryCode,
|
||||
arguments: ip,
|
||||
) ??
|
||||
'';
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> getMemory() async {
|
||||
return await _invoke<String>(method: ActionMethod.getMemory) ?? '';
|
||||
Future<int> getMemory() async {
|
||||
return await _invokeMethod<int>(method: CoreMethod.getMemory) ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
+12
-11
@@ -1,13 +1,12 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/models/core.dart';
|
||||
import 'package:fl_clash/plugins/service.dart';
|
||||
import 'package:fl_clash/providers/providers.dart';
|
||||
import 'package:fl_clash/state.dart';
|
||||
|
||||
import 'interface.dart';
|
||||
import 'method.dart';
|
||||
|
||||
class CoreLib extends CoreHandlerInterface {
|
||||
static CoreLib? _instance;
|
||||
@@ -66,19 +65,21 @@ class CoreLib extends CoreHandlerInterface {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<T?> invoke<T>({
|
||||
required ActionMethod method,
|
||||
dynamic data,
|
||||
Future<T?> invokeMethod<T>({
|
||||
required CoreMethod method,
|
||||
Object? arguments,
|
||||
Duration? timeout,
|
||||
}) async {
|
||||
final id = '${method.name}#${utils.id}';
|
||||
final result = await service
|
||||
?.invokeAction(Action(id: id, method: method, data: data))
|
||||
.withTimeout(onTimeout: () => null);
|
||||
if (result == null) {
|
||||
final id = nextMethodCallId;
|
||||
final response = await service
|
||||
?.invokeMethod(
|
||||
CoreMethodCall(id: id, method: method, arguments: arguments),
|
||||
)
|
||||
.withTimeout(timeout: timeout, onTimeout: () => null);
|
||||
if (response == null) {
|
||||
return null;
|
||||
}
|
||||
return parasResult<T>(result);
|
||||
return response.unwrap<T>();
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
enum CoreMethod {
|
||||
message,
|
||||
initClash,
|
||||
getIsInit,
|
||||
forceGc,
|
||||
shutdown,
|
||||
validateConfig,
|
||||
updateConfig,
|
||||
getConfig,
|
||||
getProxies,
|
||||
changeProxy,
|
||||
getTraffic,
|
||||
getTotalTraffic,
|
||||
resetTraffic,
|
||||
asyncTestDelay,
|
||||
getConnections,
|
||||
closeConnections,
|
||||
resetConnections,
|
||||
closeConnection,
|
||||
getExternalProviders,
|
||||
getExternalProvider,
|
||||
updateGeoData,
|
||||
updateExternalProvider,
|
||||
sideLoadExternalProvider,
|
||||
startLog,
|
||||
stopLog,
|
||||
startListener,
|
||||
stopListener,
|
||||
getCountryCode,
|
||||
getMemory,
|
||||
crash,
|
||||
setupConfig,
|
||||
deleteFile,
|
||||
updateDns,
|
||||
}
|
||||
|
||||
class CoreMethodCall {
|
||||
final String? id;
|
||||
final CoreMethod method;
|
||||
final Object? arguments;
|
||||
|
||||
const CoreMethodCall({this.id, required this.method, this.arguments});
|
||||
|
||||
factory CoreMethodCall.fromJson(Map<String, Object?> json) {
|
||||
return CoreMethodCall(
|
||||
id: json['id'] as String?,
|
||||
method: CoreMethod.values.byName(json['method'] as String),
|
||||
arguments: json['arguments'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object?> toJson() {
|
||||
return {
|
||||
if (id != null) 'id': id,
|
||||
'method': method.name,
|
||||
'arguments': arguments,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class CoreMethodError {
|
||||
final String code;
|
||||
final String message;
|
||||
final Object? details;
|
||||
|
||||
const CoreMethodError({
|
||||
required this.code,
|
||||
required this.message,
|
||||
this.details,
|
||||
});
|
||||
|
||||
factory CoreMethodError.fromJson(Map<String, Object?> json) {
|
||||
return CoreMethodError(
|
||||
code: json['code'] as String,
|
||||
message: json['message'] as String,
|
||||
details: json['details'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object?> toJson() {
|
||||
return {'code': code, 'message': message, 'details': details};
|
||||
}
|
||||
}
|
||||
|
||||
class CoreMethodResponse {
|
||||
final String? id;
|
||||
final Object? result;
|
||||
final CoreMethodError? error;
|
||||
|
||||
const CoreMethodResponse({this.id, this.result, this.error});
|
||||
|
||||
factory CoreMethodResponse.fromJson(Map<String, Object?> json) {
|
||||
final error = json['error'];
|
||||
return CoreMethodResponse(
|
||||
id: json['id'] as String?,
|
||||
result: json['result'],
|
||||
error: error is Map
|
||||
? CoreMethodError.fromJson(Map<String, Object?>.from(error))
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object?> toJson() {
|
||||
return {
|
||||
if (id != null) 'id': id,
|
||||
'result': result,
|
||||
if (error != null) 'error': error!.toJson(),
|
||||
};
|
||||
}
|
||||
|
||||
T? unwrap<T>() {
|
||||
final error = this.error;
|
||||
if (error != null) {
|
||||
throw CoreMethodException(
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
details: error.details,
|
||||
);
|
||||
}
|
||||
return result as T?;
|
||||
}
|
||||
}
|
||||
|
||||
class CoreMethodException implements Exception {
|
||||
final String code;
|
||||
final String message;
|
||||
final Object? details;
|
||||
|
||||
const CoreMethodException({
|
||||
required this.code,
|
||||
required this.message,
|
||||
this.details,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() => 'CoreMethodException($code, $message, $details)';
|
||||
}
|
||||
+102
-34
@@ -3,21 +3,24 @@ import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:fl_clash/common/common.dart';
|
||||
import 'package:fl_clash/core/core.dart';
|
||||
import 'package:fl_clash/enum/enum.dart';
|
||||
import 'package:fl_clash/models/core.dart';
|
||||
|
||||
import 'event.dart';
|
||||
import 'interface.dart';
|
||||
import 'method.dart';
|
||||
import 'transport.dart';
|
||||
|
||||
class CoreService extends CoreHandlerInterface {
|
||||
static CoreService? _instance;
|
||||
|
||||
late final IPCCoreTransport _transport;
|
||||
late final Future<void> _serverInitialization;
|
||||
StreamSubscription<String>? _dataSubscription;
|
||||
|
||||
Completer<bool> _shutdownCompleter = Completer();
|
||||
|
||||
final Map<String, Completer> _callbackCompleterMap = {};
|
||||
final Map<String, Completer<Object?>> _responseCompleters = {};
|
||||
|
||||
Process? _process;
|
||||
|
||||
@@ -30,39 +33,65 @@ class CoreService extends CoreHandlerInterface {
|
||||
_transport = IPCCoreTransport(
|
||||
address: system.isWindows ? windowsPipeName : unixSocketPath,
|
||||
);
|
||||
_initServer();
|
||||
_serverInitialization = _initServer();
|
||||
}
|
||||
|
||||
Future<void> handleResult(ActionResult result) async {
|
||||
final completer = _callbackCompleterMap[result.id];
|
||||
final data = await parasResult(result);
|
||||
if (result.id?.isEmpty == true) {
|
||||
coreEventManager.sendEvent(CoreEvent.fromJson(result.data));
|
||||
}
|
||||
if (completer?.isCompleted == true) {
|
||||
void _handleMethodCall(CoreMethodCall call) {
|
||||
if (call.method != CoreMethod.message) {
|
||||
commonPrint.log(
|
||||
'Unknown core callback method: ${call.method.name}',
|
||||
logLevel: LogLevel.warning,
|
||||
);
|
||||
return;
|
||||
}
|
||||
completer?.complete(data);
|
||||
for (final event in coreEventsFromData(call.arguments)) {
|
||||
coreEventManager.sendEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleResponse(CoreMethodResponse response) {
|
||||
final id = response.id;
|
||||
final completer = id == null ? null : _responseCompleters.remove(id);
|
||||
if (completer == null || completer.isCompleted) {
|
||||
return;
|
||||
}
|
||||
final error = response.error;
|
||||
if (error != null) {
|
||||
completer.completeError(
|
||||
CoreMethodException(
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
details: error.details,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
completer.complete(response.result);
|
||||
}
|
||||
|
||||
Future<void> _initServer() async {
|
||||
await _transport.init();
|
||||
|
||||
_transport.onDisconnect = () {
|
||||
_clearCompleter();
|
||||
_handleInvokeCrashEvent();
|
||||
if (!_shutdownCompleter.isCompleted) {
|
||||
_shutdownCompleter.complete(true);
|
||||
}
|
||||
};
|
||||
|
||||
_transport.dataStream
|
||||
_dataSubscription = _transport.dataStream
|
||||
.transform(uint8ListToListIntConverter)
|
||||
.transform(utf8.decoder)
|
||||
.listen(
|
||||
(data) async {
|
||||
try {
|
||||
final dataJson = await data.trim().commonToJSON<dynamic>();
|
||||
handleResult(ActionResult.fromJson(dataJson));
|
||||
final json = await data
|
||||
.trim()
|
||||
.commonToJSON<Map<String, Object?>>();
|
||||
if (json.containsKey('method')) {
|
||||
_handleMethodCall(CoreMethodCall.fromJson(json));
|
||||
} else {
|
||||
_handleResponse(CoreMethodResponse.fromJson(json));
|
||||
}
|
||||
} catch (e) {
|
||||
commonPrint.log(
|
||||
'Failed to parse transport data: $e',
|
||||
@@ -77,6 +106,7 @@ class CoreService extends CoreHandlerInterface {
|
||||
);
|
||||
},
|
||||
);
|
||||
await _transport.init();
|
||||
}
|
||||
|
||||
void _handleInvokeCrashEvent() {
|
||||
@@ -86,13 +116,21 @@ class CoreService extends CoreHandlerInterface {
|
||||
}
|
||||
|
||||
Future<void> start() async {
|
||||
await _serverInitialization;
|
||||
if (_process != null) {
|
||||
await shutdown(false);
|
||||
}
|
||||
if (system.isWindows && await system.checkIsAdmin()) {
|
||||
final isSuccess = await request.startCoreByHelper(_transport.address);
|
||||
if (isSuccess) {
|
||||
await _transport.connectionCompleter.future;
|
||||
try {
|
||||
await _transport.connectionCompleter.future.timeout(
|
||||
const Duration(seconds: 10),
|
||||
);
|
||||
} on TimeoutException {
|
||||
await request.stopCoreByHelper();
|
||||
rethrow;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -104,7 +142,7 @@ class CoreService extends CoreHandlerInterface {
|
||||
logLevel: LogLevel.error,
|
||||
);
|
||||
_handleInvokeCrashEvent();
|
||||
return;
|
||||
rethrow;
|
||||
}
|
||||
_process?.stdout.listen((_) {});
|
||||
_process?.stderr.listen((e) {
|
||||
@@ -113,19 +151,33 @@ class CoreService extends CoreHandlerInterface {
|
||||
commonPrint.log(error, logLevel: LogLevel.warning);
|
||||
}
|
||||
});
|
||||
await _transport.connectionCompleter.future;
|
||||
try {
|
||||
await _transport.connectionCompleter.future.timeout(
|
||||
const Duration(seconds: 10),
|
||||
);
|
||||
} on TimeoutException {
|
||||
_process?.kill();
|
||||
_process = null;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
FutureOr<bool> destroy() async {
|
||||
await shutdown(false);
|
||||
await _transport.close();
|
||||
try {
|
||||
await _transport.close();
|
||||
} finally {
|
||||
await _dataSubscription?.cancel();
|
||||
_dataSubscription = null;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> sendMessage(String message) async {
|
||||
await _serverInitialization;
|
||||
await _transport.connectionCompleter.future;
|
||||
_transport.send(message);
|
||||
await _transport.send(message);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -146,36 +198,52 @@ class CoreService extends CoreHandlerInterface {
|
||||
}
|
||||
|
||||
void _clearCompleter() {
|
||||
for (final completer in _callbackCompleterMap.values) {
|
||||
for (final completer in _responseCompleters.values) {
|
||||
completer.safeCompleter(null);
|
||||
}
|
||||
_responseCompleters.clear();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> preload() async {
|
||||
await start();
|
||||
return '';
|
||||
try {
|
||||
await start();
|
||||
return '';
|
||||
} catch (e) {
|
||||
commonPrint.log('Failed to preload core: $e', logLevel: LogLevel.error);
|
||||
return e.toString();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<T?> invoke<T>({
|
||||
required ActionMethod method,
|
||||
dynamic data,
|
||||
Future<T?> invokeMethod<T>({
|
||||
required CoreMethod method,
|
||||
Object? arguments,
|
||||
Duration? timeout,
|
||||
}) async {
|
||||
final id = '${method.name}#${utils.id}';
|
||||
_callbackCompleterMap[id] = Completer<T?>();
|
||||
sendMessage(json.encode(Action(id: id, method: method, data: data)));
|
||||
return (_callbackCompleterMap[id] as Completer<T?>).future.withTimeout(
|
||||
final id = nextMethodCallId;
|
||||
final completer = Completer<Object?>();
|
||||
_responseCompleters[id] = completer;
|
||||
try {
|
||||
await sendMessage(
|
||||
json.encode(
|
||||
CoreMethodCall(id: id, method: method, arguments: arguments),
|
||||
),
|
||||
);
|
||||
} catch (_) {
|
||||
_responseCompleters.remove(id);
|
||||
return null;
|
||||
}
|
||||
final result = await completer.future.withTimeout(
|
||||
timeout: timeout,
|
||||
onLast: () {
|
||||
final completer = _callbackCompleterMap[id];
|
||||
completer?.safeCompleter(null);
|
||||
_callbackCompleterMap.remove(id);
|
||||
final pendingResponse = _responseCompleters.remove(id);
|
||||
pendingResponse?.safeCompleter(null);
|
||||
},
|
||||
tag: id,
|
||||
onTimeout: () => null,
|
||||
);
|
||||
return result as T?;
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
+125
-53
@@ -14,81 +14,147 @@ const _typeDisconnected = 0x02;
|
||||
const _typeData = 0x03;
|
||||
const _typeError = 0x04;
|
||||
|
||||
typedef IpcServerStarter = Stream<Uint8List> Function(String address);
|
||||
typedef IpcMessageSender = Future<void> Function(List<int> data);
|
||||
typedef IpcServerStopper = Future<void> Function();
|
||||
|
||||
class IPCCoreTransport {
|
||||
final String address;
|
||||
final Duration readyTimeout;
|
||||
final IpcServerStarter _startServer;
|
||||
final IpcMessageSender _sendMessage;
|
||||
final IpcServerStopper _stopServer;
|
||||
|
||||
final StreamController<Uint8List> _dataController =
|
||||
StreamController<Uint8List>();
|
||||
StreamController<Uint8List>.broadcast();
|
||||
StreamSubscription<Uint8List>? _subscription;
|
||||
Completer<void> _completer = Completer<void>();
|
||||
Completer<void> _readyCompleter = Completer<void>();
|
||||
bool _ready = false;
|
||||
bool _initialized = false;
|
||||
bool _closing = false;
|
||||
|
||||
void Function()? onDisconnect;
|
||||
|
||||
IPCCoreTransport({required this.address});
|
||||
IPCCoreTransport({
|
||||
required this.address,
|
||||
this.readyTimeout = const Duration(seconds: 10),
|
||||
IpcServerStarter? startServer,
|
||||
IpcMessageSender? sendMessage,
|
||||
IpcServerStopper? stopServer,
|
||||
}) : _startServer = startServer ?? _restartIpcServer,
|
||||
_sendMessage = sendMessage ?? _sendIpcMessage,
|
||||
_stopServer = stopServer ?? stopIpcServer;
|
||||
|
||||
static Stream<Uint8List> _restartIpcServer(String address) {
|
||||
return restartIpcServer(name: address);
|
||||
}
|
||||
|
||||
static Future<void> _sendIpcMessage(List<int> data) {
|
||||
return sendIpcMessage(data: data);
|
||||
}
|
||||
|
||||
Completer<void> get connectionCompleter => _completer;
|
||||
|
||||
Stream<Uint8List> get dataStream => _dataController.stream;
|
||||
|
||||
Future<void> init() async {
|
||||
if (_initialized) {
|
||||
return;
|
||||
}
|
||||
_closing = false;
|
||||
try {
|
||||
final stream = restartIpcServer(name: address);
|
||||
final stream = _startServer(address);
|
||||
_subscription = stream.listen(
|
||||
(data) {
|
||||
if (data.isEmpty) return;
|
||||
final type = data[0];
|
||||
final payload = data.length > 1 ? data.sublist(1) : Uint8List(0);
|
||||
switch (type) {
|
||||
case _typeReady:
|
||||
commonPrint.log('IPC Ready');
|
||||
if (_readyCompleter.isCompleted) {
|
||||
break;
|
||||
}
|
||||
_readyCompleter.complete();
|
||||
break;
|
||||
case _typeConnected:
|
||||
commonPrint.log('IPC Connected');
|
||||
if (_completer.isCompleted) {
|
||||
break;
|
||||
}
|
||||
_completer.complete();
|
||||
break;
|
||||
case _typeDisconnected:
|
||||
commonPrint.log('IPC Disconnected');
|
||||
_completer = Completer<void>();
|
||||
onDisconnect?.call();
|
||||
break;
|
||||
case _typeData:
|
||||
_dataController.add(payload);
|
||||
break;
|
||||
case _typeError:
|
||||
final msg = utf8.decode(payload);
|
||||
commonPrint.log('IPC error: $msg', logLevel: LogLevel.error);
|
||||
break;
|
||||
default:
|
||||
commonPrint.log(
|
||||
'IPC unknown frame type: $type',
|
||||
logLevel: LogLevel.warning,
|
||||
);
|
||||
}
|
||||
},
|
||||
onError: (error) {
|
||||
commonPrint.log('IPC error: $error', logLevel: LogLevel.error);
|
||||
},
|
||||
_handleFrame,
|
||||
onError: _handleStreamError,
|
||||
onDone: _handleStreamDone,
|
||||
cancelOnError: false,
|
||||
);
|
||||
await _readyCompleter.future;
|
||||
await _readyCompleter.future.timeout(readyTimeout);
|
||||
_initialized = true;
|
||||
} catch (e) {
|
||||
commonPrint.log(
|
||||
'Failed to start IPC server: $e',
|
||||
logLevel: LogLevel.error,
|
||||
);
|
||||
await _subscription?.cancel();
|
||||
_subscription = null;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
void send(String message) {
|
||||
sendIpcMessage(data: utf8.encode(message));
|
||||
void _handleFrame(Uint8List data) {
|
||||
if (data.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final type = data[0];
|
||||
final payload = data.length > 1 ? data.sublist(1) : Uint8List(0);
|
||||
switch (type) {
|
||||
case _typeReady:
|
||||
commonPrint.log('IPC Ready');
|
||||
_ready = true;
|
||||
if (!_readyCompleter.isCompleted) {
|
||||
_readyCompleter.complete();
|
||||
}
|
||||
break;
|
||||
case _typeConnected:
|
||||
commonPrint.log('IPC Connected');
|
||||
if (!_completer.isCompleted) {
|
||||
_completer.complete();
|
||||
}
|
||||
break;
|
||||
case _typeDisconnected:
|
||||
commonPrint.log('IPC Disconnected');
|
||||
_completer = Completer<void>();
|
||||
if (!_closing) {
|
||||
onDisconnect?.call();
|
||||
}
|
||||
break;
|
||||
case _typeData:
|
||||
if (!_dataController.isClosed) {
|
||||
_dataController.add(payload);
|
||||
}
|
||||
break;
|
||||
case _typeError:
|
||||
final message = utf8.decode(payload, allowMalformed: true);
|
||||
final error = StateError('IPC error: $message');
|
||||
commonPrint.log(error.message, logLevel: LogLevel.error);
|
||||
if (!_readyCompleter.isCompleted) {
|
||||
_readyCompleter.completeError(error);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
commonPrint.log(
|
||||
'IPC unknown frame type: $type',
|
||||
logLevel: LogLevel.warning,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleStreamError(Object error, StackTrace stackTrace) {
|
||||
commonPrint.log('IPC error: $error', logLevel: LogLevel.error);
|
||||
if (!_readyCompleter.isCompleted) {
|
||||
_readyCompleter.completeError(error, stackTrace);
|
||||
} else if (!_dataController.isClosed) {
|
||||
_dataController.addError(error, stackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleStreamDone() {
|
||||
if (!_readyCompleter.isCompleted) {
|
||||
_readyCompleter.completeError(
|
||||
StateError('IPC server stopped before becoming ready'),
|
||||
);
|
||||
}
|
||||
if (_ready && !_closing) {
|
||||
_completer = Completer<void>();
|
||||
onDisconnect?.call();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> send(String message) {
|
||||
return _sendMessage(utf8.encode(message));
|
||||
}
|
||||
|
||||
void disconnected() {
|
||||
@@ -96,11 +162,17 @@ class IPCCoreTransport {
|
||||
}
|
||||
|
||||
Future<void> close() async {
|
||||
await _subscription?.cancel();
|
||||
_subscription = null;
|
||||
await stopIpcServer();
|
||||
_readyCompleter = Completer<void>();
|
||||
_completer = Completer<void>();
|
||||
await _dataController.close();
|
||||
_closing = true;
|
||||
try {
|
||||
await _stopServer();
|
||||
} finally {
|
||||
await _subscription?.cancel();
|
||||
_subscription = null;
|
||||
_ready = false;
|
||||
_initialized = false;
|
||||
_readyCompleter = Completer<void>();
|
||||
_completer = Completer<void>();
|
||||
await _dataController.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-49
@@ -219,50 +219,6 @@ enum FontFamily {
|
||||
|
||||
enum RouteMode { bypassPrivate, config }
|
||||
|
||||
enum ActionMethod {
|
||||
message,
|
||||
initClash,
|
||||
getIsInit,
|
||||
forceGc,
|
||||
shutdown,
|
||||
validateConfig,
|
||||
updateConfig,
|
||||
getConfig,
|
||||
getProxies,
|
||||
changeProxy,
|
||||
getTraffic,
|
||||
getTotalTraffic,
|
||||
resetTraffic,
|
||||
asyncTestDelay,
|
||||
getConnections,
|
||||
closeConnections,
|
||||
resetConnections,
|
||||
closeConnection,
|
||||
getExternalProviders,
|
||||
getExternalProvider,
|
||||
updateGeoData,
|
||||
updateExternalProvider,
|
||||
sideLoadExternalProvider,
|
||||
startLog,
|
||||
stopLog,
|
||||
startListener,
|
||||
stopListener,
|
||||
getCountryCode,
|
||||
getMemory,
|
||||
crash,
|
||||
setupConfig,
|
||||
deleteFile,
|
||||
|
||||
///Android,
|
||||
setState,
|
||||
startTun,
|
||||
stopTun,
|
||||
getRunTime,
|
||||
updateDns,
|
||||
getAndroidVpnOptions,
|
||||
getCurrentProfileName,
|
||||
}
|
||||
|
||||
enum AuthorizeCode { none, success, error }
|
||||
|
||||
enum WindowsHelperServiceStatus { none, presence, running }
|
||||
@@ -336,9 +292,9 @@ enum GeoResource {
|
||||
MMDB,
|
||||
@JsonValue('asn')
|
||||
ASN,
|
||||
@JsonValue('geo-ip')
|
||||
@JsonValue('geoip')
|
||||
GEOIP,
|
||||
@JsonValue('geo-site')
|
||||
@JsonValue('geosite')
|
||||
GEOSITE;
|
||||
|
||||
static GeoResource fromJson(String value) {
|
||||
@@ -353,12 +309,12 @@ enum GeoResource {
|
||||
}
|
||||
|
||||
extension GeoResourceExt on GeoResource {
|
||||
String get value {
|
||||
String get configKey {
|
||||
return switch (this) {
|
||||
GeoResource.MMDB => 'mmdb',
|
||||
GeoResource.ASN => 'asn',
|
||||
GeoResource.GEOIP => 'geo-ip',
|
||||
GeoResource.GEOSITE => 'geo-site',
|
||||
GeoResource.GEOIP => 'geoip',
|
||||
GeoResource.GEOSITE => 'geosite',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -272,6 +272,10 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"core": MessageLookupByLibrary.simpleMessage("Core"),
|
||||
"coreStatus": MessageLookupByLibrary.simpleMessage("Core status"),
|
||||
"country": MessageLookupByLibrary.simpleMessage("Country"),
|
||||
"crashDetected": MessageLookupByLibrary.simpleMessage("Crash detected"),
|
||||
"crashDetectedTip": MessageLookupByLibrary.simpleMessage(
|
||||
"The app crashed during the previous run. To prevent repeated crashes, the current profile has been cleared and automatic configuration setup was skipped.",
|
||||
),
|
||||
"crashTest": MessageLookupByLibrary.simpleMessage("Crash test"),
|
||||
"crashlytics": MessageLookupByLibrary.simpleMessage("Crash Analysis"),
|
||||
"crashlyticsTip": MessageLookupByLibrary.simpleMessage(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user