Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45b163184d | ||
|
|
2ab70f193a | ||
|
|
ed7868282a | ||
|
|
e956373ef4 | ||
|
|
1154e7b245 | ||
|
|
adb890d763 | ||
|
|
1477f9bd9c | ||
|
|
a06e813249 | ||
|
|
afbc5adb05 | ||
|
|
76c9f08d4a | ||
|
|
f83a8e0cce | ||
|
|
f5544f1af7 | ||
|
|
eeb543780a |
21
.github/workflows/build.yaml
vendored
21
.github/workflows/build.yaml
vendored
@@ -16,7 +16,7 @@ jobs:
|
|||||||
- platform: android
|
- platform: android
|
||||||
os: ubuntu-latest
|
os: ubuntu-latest
|
||||||
- platform: windows
|
- platform: windows
|
||||||
os: windows-latest
|
os: Windows-2022
|
||||||
arch: amd64
|
arch: amd64
|
||||||
- platform: linux
|
- platform: linux
|
||||||
os: ubuntu-22.04
|
os: ubuntu-22.04
|
||||||
@@ -27,9 +27,9 @@ jobs:
|
|||||||
- platform: macos
|
- platform: macos
|
||||||
os: macos-latest
|
os: macos-latest
|
||||||
arch: arm64
|
arch: arm64
|
||||||
- platform: windows
|
# - platform: windows
|
||||||
os: windows-11-arm
|
# os: windows-11-arm
|
||||||
arch: arm64
|
# arch: arm64
|
||||||
- platform: linux
|
- platform: linux
|
||||||
os: ubuntu-24.04-arm
|
os: ubuntu-24.04-arm
|
||||||
arch: arm64
|
arch: arm64
|
||||||
@@ -52,6 +52,7 @@ jobs:
|
|||||||
if: startsWith(matrix.platform,'android')
|
if: startsWith(matrix.platform,'android')
|
||||||
run: |
|
run: |
|
||||||
echo "${{ secrets.KEYSTORE }}" | base64 --decode > android/app/keystore.jks
|
echo "${{ secrets.KEYSTORE }}" | base64 --decode > android/app/keystore.jks
|
||||||
|
echo "${{ secrets.SERVICE_JSON }}" | base64 --decode > android/app/google-services.json
|
||||||
echo "keyAlias=${{ secrets.KEY_ALIAS }}" >> android/local.properties
|
echo "keyAlias=${{ secrets.KEY_ALIAS }}" >> android/local.properties
|
||||||
echo "storePassword=${{ secrets.STORE_PASSWORD }}" >> android/local.properties
|
echo "storePassword=${{ secrets.STORE_PASSWORD }}" >> android/local.properties
|
||||||
echo "keyPassword=${{ secrets.KEY_PASSWORD }}" >> android/local.properties
|
echo "keyPassword=${{ secrets.KEY_PASSWORD }}" >> android/local.properties
|
||||||
@@ -63,11 +64,19 @@ jobs:
|
|||||||
cache-dependency-path: |
|
cache-dependency-path: |
|
||||||
core/go.sum
|
core/go.sum
|
||||||
|
|
||||||
- name: Setup Flutter
|
- name: Setup Flutter Master
|
||||||
|
if: startsWith(matrix.os, 'windows-11-arm') || startsWith(matrix.os, 'ubuntu-24.04-arm')
|
||||||
uses: subosito/flutter-action@v2
|
uses: subosito/flutter-action@v2
|
||||||
with:
|
with:
|
||||||
channel: ${{ (startsWith(matrix.os, 'windows-11-arm') || startsWith(matrix.os, 'ubuntu-24.04-arm')) && 'master' || 'stable' }}
|
channel: 'master'
|
||||||
cache: true
|
cache: true
|
||||||
|
- name: Setup Flutter
|
||||||
|
if: ${{ !(startsWith(matrix.os, 'windows-11-arm') || startsWith(matrix.os, 'ubuntu-24.04-arm')) }}
|
||||||
|
uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
channel: 'stable'
|
||||||
|
cache: true
|
||||||
|
# flutter-version: 3.29.3
|
||||||
|
|
||||||
- name: Get Flutter Dependency
|
- name: Get Flutter Dependency
|
||||||
run: flutter pub get
|
run: flutter pub get
|
||||||
|
|||||||
16
.gitignore
vendored
16
.gitignore
vendored
@@ -45,11 +45,19 @@ app.*.map.json
|
|||||||
/android/app/debug
|
/android/app/debug
|
||||||
/android/app/profile
|
/android/app/profile
|
||||||
/android/app/release
|
/android/app/release
|
||||||
|
/android/**/.cxx
|
||||||
|
/android/**/build
|
||||||
|
/android/common/**/.**/
|
||||||
|
/android/common/local.*
|
||||||
|
/android/core/**/includes/
|
||||||
|
/android/core/**/cmake-build-*/
|
||||||
|
/android/core/**/jniLibs/
|
||||||
|
|
||||||
|
|
||||||
|
#FlClash
|
||||||
#libclash
|
|
||||||
/libclash/
|
/libclash/
|
||||||
|
|
||||||
#jniLibs
|
|
||||||
/android/app/src/main/jniLibs/
|
/android/app/src/main/jniLibs/
|
||||||
|
/services/helper/target
|
||||||
|
/macos/**/Package.resolved
|
||||||
|
devtools_options.yaml
|
||||||
|
|
||||||
|
|||||||
96
CHANGELOG.md
96
CHANGELOG.md
@@ -1,3 +1,99 @@
|
|||||||
|
## v0.8.88
|
||||||
|
|
||||||
|
- Add android separates the core process
|
||||||
|
|
||||||
|
- Support core status check and force restart
|
||||||
|
|
||||||
|
- Optimize proxies page and access page
|
||||||
|
|
||||||
|
- Update flutter and pub dependencies
|
||||||
|
|
||||||
|
- Update go version
|
||||||
|
|
||||||
|
- Optimize more details
|
||||||
|
|
||||||
|
- Update changelog
|
||||||
|
|
||||||
|
## v0.8.87
|
||||||
|
|
||||||
|
- Optimize desktop view
|
||||||
|
|
||||||
|
- Optimize logs, requests, connection pages
|
||||||
|
|
||||||
|
- Optimize windows tray auto hide
|
||||||
|
|
||||||
|
- Optimize some details
|
||||||
|
|
||||||
|
- Update core
|
||||||
|
|
||||||
|
- Update changelog
|
||||||
|
|
||||||
|
## v0.8.86
|
||||||
|
|
||||||
|
- Fix windows tun issues
|
||||||
|
|
||||||
|
- Optimize android get system dns
|
||||||
|
|
||||||
|
- Optimize more details
|
||||||
|
|
||||||
|
- Update changelog
|
||||||
|
|
||||||
|
## v0.8.85
|
||||||
|
|
||||||
|
- Support override script
|
||||||
|
|
||||||
|
- Support proxies search
|
||||||
|
|
||||||
|
- Support svg display
|
||||||
|
|
||||||
|
- Optimize config persistence
|
||||||
|
|
||||||
|
- Add some scenes auto close connections
|
||||||
|
|
||||||
|
- Update core
|
||||||
|
|
||||||
|
- Optimize more details
|
||||||
|
|
||||||
|
## v0.8.84
|
||||||
|
|
||||||
|
- Fix windows service verify issues
|
||||||
|
|
||||||
|
- Update changelog
|
||||||
|
|
||||||
|
## v0.8.83
|
||||||
|
|
||||||
|
- Add windows server mode start process verify
|
||||||
|
|
||||||
|
- Add linux deb dependencies
|
||||||
|
|
||||||
|
- Add backup recovery strategy select
|
||||||
|
|
||||||
|
- Support custom text scaling
|
||||||
|
|
||||||
|
- Optimize the display of different text scale
|
||||||
|
|
||||||
|
- Optimize windows setup experience
|
||||||
|
|
||||||
|
- Optimize startTun performance
|
||||||
|
|
||||||
|
- Optimize android tv experience
|
||||||
|
|
||||||
|
- Optimize default option
|
||||||
|
|
||||||
|
- Optimize computed text size
|
||||||
|
|
||||||
|
- Optimize hyperOS freeform window
|
||||||
|
|
||||||
|
- Add developer mode
|
||||||
|
|
||||||
|
- Update core
|
||||||
|
|
||||||
|
- Optimize more details
|
||||||
|
|
||||||
|
- Add issues template
|
||||||
|
|
||||||
|
- Update changelog
|
||||||
|
|
||||||
## v0.8.82
|
## v0.8.82
|
||||||
|
|
||||||
- Optimize android vpn performance
|
- Optimize android vpn performance
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ Support the following actions
|
|||||||
|
|
||||||
com.follow.clash.action.STOP
|
com.follow.clash.action.STOP
|
||||||
|
|
||||||
com.follow.clash.action.CHANGE
|
com.follow.clash.action.TOGGLE
|
||||||
```
|
```
|
||||||
|
|
||||||
## Download
|
## Download
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ on Mobile:
|
|||||||
|
|
||||||
com.follow.clash.action.STOP
|
com.follow.clash.action.STOP
|
||||||
|
|
||||||
com.follow.clash.action.CHANGE
|
com.follow.clash.action.TOGGLE
|
||||||
```
|
```
|
||||||
|
|
||||||
## Download
|
## Download
|
||||||
|
|||||||
@@ -1 +1,10 @@
|
|||||||
include: package:flutter_lints/flutter.yaml
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
analyzer:
|
||||||
|
exclude:
|
||||||
|
- lib/l10n/intl/**
|
||||||
|
errors:
|
||||||
|
invalid_annotation_target: ignore
|
||||||
|
|
||||||
|
linter:
|
||||||
|
rules:
|
||||||
|
prefer_single_quotes: true
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
plugins {
|
|
||||||
id "com.android.application"
|
|
||||||
id "kotlin-android"
|
|
||||||
id "dev.flutter.flutter-gradle-plugin"
|
|
||||||
}
|
|
||||||
|
|
||||||
def localProperties = new Properties()
|
|
||||||
def localPropertiesFile = rootProject.file('local.properties')
|
|
||||||
if (localPropertiesFile.exists()) {
|
|
||||||
localPropertiesFile.withReader('UTF-8') { reader ->
|
|
||||||
localProperties.load(reader)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
|
|
||||||
if (flutterVersionCode == null) {
|
|
||||||
flutterVersionCode = '1'
|
|
||||||
}
|
|
||||||
|
|
||||||
def flutterVersionName = localProperties.getProperty('flutter.versionName')
|
|
||||||
if (flutterVersionName == null) {
|
|
||||||
flutterVersionName = '1.0'
|
|
||||||
}
|
|
||||||
|
|
||||||
def defStoreFile = file("keystore.jks")
|
|
||||||
def defStorePassword = localProperties.getProperty('storePassword')
|
|
||||||
def defKeyAlias = localProperties.getProperty('keyAlias')
|
|
||||||
def defKeyPassword = localProperties.getProperty('keyPassword')
|
|
||||||
def isRelease = defStoreFile.exists() && defStorePassword != null && defKeyAlias != null && defKeyPassword != null
|
|
||||||
|
|
||||||
android {
|
|
||||||
namespace "com.follow.clash"
|
|
||||||
compileSdk 35
|
|
||||||
ndkVersion = "28.0.13004108"
|
|
||||||
|
|
||||||
compileOptions {
|
|
||||||
sourceCompatibility JavaVersion.VERSION_17
|
|
||||||
targetCompatibility JavaVersion.VERSION_17
|
|
||||||
}
|
|
||||||
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = '17'
|
|
||||||
}
|
|
||||||
|
|
||||||
sourceSets {
|
|
||||||
main.java.srcDirs += 'src/main/kotlin'
|
|
||||||
}
|
|
||||||
|
|
||||||
signingConfigs {
|
|
||||||
if (isRelease) {
|
|
||||||
release {
|
|
||||||
storeFile defStoreFile
|
|
||||||
storePassword defStorePassword
|
|
||||||
keyAlias defKeyAlias
|
|
||||||
keyPassword defKeyPassword
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
defaultConfig {
|
|
||||||
applicationId "com.follow.clash"
|
|
||||||
minSdkVersion 21
|
|
||||||
targetSdkVersion 35
|
|
||||||
versionCode flutterVersionCode.toInteger()
|
|
||||||
versionName flutterVersionName
|
|
||||||
}
|
|
||||||
|
|
||||||
buildTypes {
|
|
||||||
debug {
|
|
||||||
minifyEnabled false
|
|
||||||
applicationIdSuffix '.debug'
|
|
||||||
}
|
|
||||||
release {
|
|
||||||
if (isRelease) {
|
|
||||||
signingConfig signingConfigs.release
|
|
||||||
} else {
|
|
||||||
signingConfig signingConfigs.debug
|
|
||||||
}
|
|
||||||
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
flutter {
|
|
||||||
source '../..'
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
implementation project(":core")
|
|
||||||
implementation 'androidx.core:core-splashscreen:1.0.1'
|
|
||||||
implementation 'com.google.code.gson:gson:2.10.1'
|
|
||||||
implementation("com.android.tools.smali:smali-dexlib2:3.0.9") {
|
|
||||||
exclude group: "com.google.guava", module: "guava"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
108
android/app/build.gradle.kts
Normal file
108
android/app/build.gradle.kts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
|
import java.util.Properties
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
id("kotlin-android")
|
||||||
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
|
id("com.google.gms.google-services")
|
||||||
|
id("com.google.firebase.crashlytics")
|
||||||
|
}
|
||||||
|
|
||||||
|
val localPropertiesFile = rootProject.file("local.properties")
|
||||||
|
val localProperties = Properties().apply {
|
||||||
|
if (localPropertiesFile.exists()) {
|
||||||
|
localPropertiesFile.inputStream().use { load(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val mStoreFile: File = file("keystore.jks")
|
||||||
|
val mStorePassword: String? = localProperties.getProperty("storePassword")
|
||||||
|
val mKeyAlias: String? = localProperties.getProperty("keyAlias")
|
||||||
|
val mKeyPassword: String? = localProperties.getProperty("keyPassword")
|
||||||
|
val isRelease =
|
||||||
|
mStoreFile.exists() && mStorePassword != null && mKeyAlias != null && mKeyPassword != null
|
||||||
|
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.follow.clash"
|
||||||
|
compileSdk = libs.versions.compileSdk.get().toInt()
|
||||||
|
ndkVersion = libs.versions.ndkVersion.get()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "com.follow.clash"
|
||||||
|
minSdk = flutter.minSdkVersion
|
||||||
|
targetSdk = libs.versions.targetSdk.get().toInt()
|
||||||
|
versionCode = flutter.versionCode
|
||||||
|
versionName = flutter.versionName
|
||||||
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
if (isRelease) {
|
||||||
|
create("release") {
|
||||||
|
storeFile = mStoreFile
|
||||||
|
storePassword = mStorePassword
|
||||||
|
keyAlias = mKeyAlias
|
||||||
|
keyPassword = mKeyPassword
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
packaging {
|
||||||
|
jniLibs {
|
||||||
|
useLegacyPackaging = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
debug {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
applicationIdSuffix = ".debug"
|
||||||
|
}
|
||||||
|
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = true
|
||||||
|
isShrinkResources = true
|
||||||
|
signingConfig = if (isRelease) {
|
||||||
|
signingConfigs.getByName("release")
|
||||||
|
} else {
|
||||||
|
signingConfigs.getByName("debug")
|
||||||
|
}
|
||||||
|
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
compilerOptions {
|
||||||
|
jvmTarget.set(JvmTarget.JVM_17)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flutter {
|
||||||
|
source = "../.."
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":service"))
|
||||||
|
implementation(project(":common"))
|
||||||
|
implementation(libs.core.splashscreen)
|
||||||
|
implementation(libs.gson)
|
||||||
|
implementation(libs.smali.dexlib2) {
|
||||||
|
exclude(group = "com.google.guava", module = "guava")
|
||||||
|
}
|
||||||
|
implementation(platform(libs.firebase.bom))
|
||||||
|
implementation(libs.firebase.crashlytics.ndk)
|
||||||
|
implementation(libs.firebase.analytics)
|
||||||
|
}
|
||||||
46
android/app/google-services.json
Normal file
46
android/app/google-services.json
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"project_info": {
|
||||||
|
"project_number": "000000000000",
|
||||||
|
"project_id": "dev"
|
||||||
|
},
|
||||||
|
"client": [
|
||||||
|
{
|
||||||
|
"client_info": {
|
||||||
|
"mobilesdk_app_id": "1:000000000000:android:0000000000000000",
|
||||||
|
"android_client_info": {
|
||||||
|
"package_name": "com.follow.clash"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oauth_client": [],
|
||||||
|
"api_key": [
|
||||||
|
{
|
||||||
|
"current_key": "0"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": {
|
||||||
|
"appinvite_service": {
|
||||||
|
"other_platform_oauth_client": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"client_info": {
|
||||||
|
"mobilesdk_app_id": "1:000000000000:android:0000000000000000",
|
||||||
|
"android_client_info": {
|
||||||
|
"package_name": "com.follow.clash.debug"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oauth_client": [],
|
||||||
|
"api_key": [
|
||||||
|
{
|
||||||
|
"current_key": "0"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": {
|
||||||
|
"appinvite_service": {
|
||||||
|
"other_platform_oauth_client": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
4
android/app/proguard-rules.pro
vendored
4
android/app/proguard-rules.pro
vendored
@@ -1,2 +1,4 @@
|
|||||||
|
|
||||||
-keep class com.follow.clash.models.**{ *; }
|
-keep class com.follow.clash.models.**{ *; }
|
||||||
|
|
||||||
|
-keep class com.follow.clash.service.models.**{ *; }
|
||||||
@@ -9,9 +9,8 @@
|
|||||||
android:label="FlClash Debug"
|
android:label="FlClash Debug"
|
||||||
tools:replace="android:label">
|
tools:replace="android:label">
|
||||||
<service
|
<service
|
||||||
android:name=".services.FlClashTileService"
|
android:name=".TileService"
|
||||||
android:label="FlClash Debug"
|
android:label="FlClash Debug"
|
||||||
tools:replace="android:label"
|
tools:replace="android:label" />
|
||||||
tools:targetApi="24" />
|
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
@@ -7,42 +8,42 @@
|
|||||||
<uses-feature
|
<uses-feature
|
||||||
android:name="android.hardware.camera"
|
android:name="android.hardware.camera"
|
||||||
android:required="false" />
|
android:required="false" />
|
||||||
|
<uses-feature
|
||||||
|
android:name="android.software.leanback"
|
||||||
|
android:required="false" />
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
|
||||||
|
|
||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.QUERY_ALL_PACKAGES"
|
android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||||
tools:ignore="QueryAllPackagesPermission" />
|
tools:ignore="QueryAllPackagesPermission" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".FlClashApplication"
|
android:name=".Application"
|
||||||
|
android:banner="@mipmap/ic_banner"
|
||||||
android:hardwareAccelerated="true"
|
android:hardwareAccelerated="true"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="FlClash">
|
android:label="FlClash">
|
||||||
<activity
|
<activity
|
||||||
android:name="com.follow.clash.MainActivity"
|
android:name=".MainActivity"
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:hardwareAccelerated="true"
|
android:hardwareAccelerated="true"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleTop"
|
||||||
android:theme="@style/LaunchTheme"
|
android:theme="@style/LaunchTheme"
|
||||||
android:windowSoftInputMode="adjustResize">
|
android:windowSoftInputMode="adjustResize">
|
||||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
|
||||||
the Android process has started. This theme is visible to the user
|
|
||||||
while the Flutter UI initializes. After that, this theme continues
|
|
||||||
to determine the Window background behind the Flutter UI. -->
|
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="io.flutter.embedding.android.NormalTheme"
|
android:name="io.flutter.embedding.android.NormalTheme"
|
||||||
android:resource="@style/NormalTheme" />
|
android:resource="@style/NormalTheme" />
|
||||||
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
@@ -63,10 +64,6 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<meta-data
|
|
||||||
android:name="io.flutter.embedding.android.EnableImpeller"
|
|
||||||
android:value="false" />
|
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".TempActivity"
|
android:name=".TempActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
@@ -81,17 +78,16 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<action android:name="${applicationId}.action.CHANGE" />
|
<action android:name="${applicationId}.action.TOGGLE" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name=".services.FlClashTileService"
|
android:name=".TileService"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:icon="@drawable/ic_stat_name"
|
android:icon="@drawable/ic"
|
||||||
android:label="FlClash"
|
android:label="FlClash"
|
||||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
|
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
|
||||||
tools:targetApi="n">
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
@@ -100,49 +96,16 @@
|
|||||||
android:value="true" />
|
android:value="true" />
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
<provider
|
<receiver
|
||||||
android:name=".FilesProvider"
|
android:name=".BroadcastReceiver"
|
||||||
android:authorities="${applicationId}.files"
|
android:enabled="true"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:grantUriPermissions="true"
|
android:permission="${applicationId}.permission.RECEIVE_BROADCASTS">
|
||||||
android:permission="android.permission.MANAGE_DOCUMENTS"
|
|
||||||
android:process=":background">
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
|
<action android:name="${applicationId}.intent.action.SERVICE_CREATED" />
|
||||||
|
<action android:name="${applicationId}.intent.action.SERVICE_DESTROYED" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</provider>
|
</receiver>
|
||||||
|
|
||||||
<provider
|
|
||||||
android:name="androidx.core.content.FileProvider"
|
|
||||||
android:authorities="${applicationId}.fileProvider"
|
|
||||||
android:exported="false"
|
|
||||||
android:grantUriPermissions="true">
|
|
||||||
<meta-data
|
|
||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
|
||||||
android:resource="@xml/file_paths" />
|
|
||||||
</provider>
|
|
||||||
|
|
||||||
<service
|
|
||||||
android:name=".services.FlClashVpnService"
|
|
||||||
android:exported="false"
|
|
||||||
android:foregroundServiceType="dataSync"
|
|
||||||
android:permission="android.permission.BIND_VPN_SERVICE">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.net.VpnService" />
|
|
||||||
</intent-filter>
|
|
||||||
<property
|
|
||||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
|
||||||
android:value="vpn" />
|
|
||||||
</service>
|
|
||||||
|
|
||||||
<service
|
|
||||||
android:name=".services.FlClashService"
|
|
||||||
android:exported="false"
|
|
||||||
android:foregroundServiceType="dataSync">
|
|
||||||
<property
|
|
||||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
|
||||||
android:value="service" />
|
|
||||||
</service>
|
|
||||||
|
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
|
|||||||
13
android/app/src/main/kotlin/com/follow/clash/Application.kt
Normal file
13
android/app/src/main/kotlin/com/follow/clash/Application.kt
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package com.follow.clash
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.Context
|
||||||
|
import com.follow.clash.common.GlobalState
|
||||||
|
|
||||||
|
class Application : Application() {
|
||||||
|
|
||||||
|
override fun attachBaseContext(base: Context?) {
|
||||||
|
super.attachBaseContext(base)
|
||||||
|
GlobalState.init(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.follow.clash
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import com.follow.clash.common.BroadcastAction
|
||||||
|
import com.follow.clash.common.GlobalState
|
||||||
|
import com.follow.clash.common.action
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class BroadcastReceiver : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
|
when (intent?.action) {
|
||||||
|
BroadcastAction.SERVICE_CREATED.action -> {
|
||||||
|
GlobalState.log("Receiver service created")
|
||||||
|
GlobalState.launch {
|
||||||
|
State.handleStartServiceAction()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BroadcastAction.SERVICE_DESTROYED.action -> {
|
||||||
|
GlobalState.log("Receiver service destroyed")
|
||||||
|
State.handleStopServiceAction()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
121
android/app/src/main/kotlin/com/follow/clash/Ext.kt
Normal file
121
android/app/src/main/kotlin/com/follow/clash/Ext.kt
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
package com.follow.clash
|
||||||
|
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import androidx.core.graphics.drawable.toBitmap
|
||||||
|
import com.follow.clash.common.GlobalState
|
||||||
|
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
|
||||||
|
|
||||||
|
suspend fun PackageManager.getPackageIconPath(packageName: String): String =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val cacheDir = GlobalState.application.cacheDir
|
||||||
|
val iconDir = File(cacheDir, "icons").apply { mkdirs() }
|
||||||
|
return@withContext try {
|
||||||
|
val pkgInfo = getPackageInfo(packageName, 0)
|
||||||
|
val lastUpdateTime = pkgInfo.lastUpdateTime
|
||||||
|
val iconFile = File(iconDir, "${packageName}_${lastUpdateTime}.webp")
|
||||||
|
if (iconFile.exists() && !isExpired(iconFile)) {
|
||||||
|
return@withContext iconFile.absolutePath
|
||||||
|
}
|
||||||
|
iconDir.listFiles { f -> f.name.startsWith("${packageName}_") }?.forEach(File::delete)
|
||||||
|
|
||||||
|
val icon = getApplicationIcon(packageName)
|
||||||
|
saveDrawableToFile(icon, iconFile)
|
||||||
|
iconFile.absolutePath
|
||||||
|
} catch (_: Exception) {
|
||||||
|
val defaultIconFile = File(iconDir, "default_icon.webp")
|
||||||
|
if (!defaultIconFile.exists()) {
|
||||||
|
saveDrawableToFile(defaultActivityIcon, defaultIconFile)
|
||||||
|
}
|
||||||
|
defaultIconFile.absolutePath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun saveDrawableToFile(drawable: Drawable, file: File) {
|
||||||
|
val bitmap = withContext(Dispatchers.Default) {
|
||||||
|
drawable.toBitmap(width = 128, height = 128)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val format = when {
|
||||||
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> {
|
||||||
|
Bitmap.CompressFormat.WEBP_LOSSY
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
Bitmap.CompressFormat.WEBP
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FileOutputStream(file).use { fos ->
|
||||||
|
bitmap.compress(format, 90, fos)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!bitmap.isRecycled) bitmap.recycle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isExpired(file: File): Boolean {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val age = now - file.lastModified()
|
||||||
|
return age > TimeUnit.DAYS.toMillis(ICON_TTL_DAYS)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
) {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
package com.follow.clash;
|
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import android.content.Context
|
|
||||||
|
|
||||||
class FlClashApplication : Application() {
|
|
||||||
companion object {
|
|
||||||
private lateinit var instance: FlClashApplication
|
|
||||||
fun getAppContext(): Context {
|
|
||||||
return instance.applicationContext
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate() {
|
|
||||||
super.onCreate()
|
|
||||||
instance = this
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
package com.follow.clash
|
|
||||||
|
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import com.follow.clash.plugins.AppPlugin
|
|
||||||
import com.follow.clash.plugins.TilePlugin
|
|
||||||
import com.follow.clash.plugins.VpnPlugin
|
|
||||||
import io.flutter.FlutterInjector
|
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
|
||||||
import io.flutter.embedding.engine.dart.DartExecutor
|
|
||||||
import java.util.concurrent.locks.ReentrantLock
|
|
||||||
import kotlin.concurrent.withLock
|
|
||||||
|
|
||||||
enum class RunState {
|
|
||||||
START,
|
|
||||||
PENDING,
|
|
||||||
STOP
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
object GlobalState {
|
|
||||||
val runLock = ReentrantLock()
|
|
||||||
|
|
||||||
const val NOTIFICATION_CHANNEL = "FlClash"
|
|
||||||
|
|
||||||
const val NOTIFICATION_ID = 1
|
|
||||||
|
|
||||||
val runState: MutableLiveData<RunState> = MutableLiveData<RunState>(RunState.STOP)
|
|
||||||
var flutterEngine: FlutterEngine? = null
|
|
||||||
private var serviceEngine: FlutterEngine? = null
|
|
||||||
|
|
||||||
fun getCurrentAppPlugin(): AppPlugin? {
|
|
||||||
val currentEngine = if (flutterEngine != null) flutterEngine else serviceEngine
|
|
||||||
return currentEngine?.plugins?.get(AppPlugin::class.java) as AppPlugin?
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getText(text: String): String {
|
|
||||||
return getCurrentAppPlugin()?.getText(text) ?: ""
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getCurrentTilePlugin(): TilePlugin? {
|
|
||||||
val currentEngine = if (flutterEngine != null) flutterEngine else serviceEngine
|
|
||||||
return currentEngine?.plugins?.get(TilePlugin::class.java) as TilePlugin?
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getCurrentVPNPlugin(): VpnPlugin? {
|
|
||||||
return serviceEngine?.plugins?.get(VpnPlugin::class.java) as VpnPlugin?
|
|
||||||
}
|
|
||||||
|
|
||||||
fun handleToggle() {
|
|
||||||
val starting = handleStart()
|
|
||||||
if (!starting) {
|
|
||||||
handleStop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun handleStart(): Boolean {
|
|
||||||
if (runState.value == RunState.STOP) {
|
|
||||||
runState.value = RunState.PENDING
|
|
||||||
runLock.lock()
|
|
||||||
val tilePlugin = getCurrentTilePlugin()
|
|
||||||
if (tilePlugin != null) {
|
|
||||||
tilePlugin.handleStart()
|
|
||||||
} else {
|
|
||||||
initServiceEngine()
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
fun handleStop() {
|
|
||||||
if (runState.value == RunState.START) {
|
|
||||||
runState.value = RunState.PENDING
|
|
||||||
runLock.lock()
|
|
||||||
getCurrentTilePlugin()?.handleStop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun handleTryDestroy() {
|
|
||||||
if (flutterEngine == null) {
|
|
||||||
destroyServiceEngine()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun destroyServiceEngine() {
|
|
||||||
runLock.withLock {
|
|
||||||
serviceEngine?.destroy()
|
|
||||||
serviceEngine = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun initServiceEngine() {
|
|
||||||
if (serviceEngine != null) return
|
|
||||||
destroyServiceEngine()
|
|
||||||
runLock.withLock {
|
|
||||||
serviceEngine = FlutterEngine(FlClashApplication.getAppContext())
|
|
||||||
serviceEngine?.plugins?.add(VpnPlugin)
|
|
||||||
serviceEngine?.plugins?.add(AppPlugin())
|
|
||||||
serviceEngine?.plugins?.add(TilePlugin())
|
|
||||||
val vpnService = DartExecutor.DartEntrypoint(
|
|
||||||
FlutterInjector.instance().flutterLoader().findAppBundlePath(),
|
|
||||||
"_service"
|
|
||||||
)
|
|
||||||
serviceEngine?.dartExecutor?.executeDartEntrypoint(
|
|
||||||
vpnService,
|
|
||||||
if (flutterEngine == null) listOf("quick") else null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,22 +1,41 @@
|
|||||||
package com.follow.clash
|
package com.follow.clash
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import com.follow.clash.common.GlobalState
|
||||||
import com.follow.clash.plugins.AppPlugin
|
import com.follow.clash.plugins.AppPlugin
|
||||||
import com.follow.clash.plugins.ServicePlugin
|
import com.follow.clash.plugins.ServicePlugin
|
||||||
import com.follow.clash.plugins.TilePlugin
|
import com.follow.clash.plugins.TilePlugin
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class MainActivity : FlutterActivity(),
|
||||||
|
CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) {
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
lifecycleScope.launch {
|
||||||
|
State.destroyServiceEngine()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class MainActivity : FlutterActivity() {
|
|
||||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
super.configureFlutterEngine(flutterEngine)
|
super.configureFlutterEngine(flutterEngine)
|
||||||
flutterEngine.plugins.add(AppPlugin())
|
flutterEngine.plugins.add(AppPlugin())
|
||||||
flutterEngine.plugins.add(ServicePlugin)
|
flutterEngine.plugins.add(ServicePlugin())
|
||||||
flutterEngine.plugins.add(TilePlugin())
|
flutterEngine.plugins.add(TilePlugin())
|
||||||
GlobalState.flutterEngine = flutterEngine
|
State.flutterEngine = flutterEngine
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
GlobalState.flutterEngine = null
|
GlobalState.launch {
|
||||||
|
Service.setEventListener(null)
|
||||||
|
}
|
||||||
|
State.flutterEngine = null
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
147
android/app/src/main/kotlin/com/follow/clash/Service.kt
Normal file
147
android/app/src/main/kotlin/com/follow/clash/Service.kt
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
package com.follow.clash
|
||||||
|
|
||||||
|
import com.follow.clash.common.ServiceDelegate
|
||||||
|
import com.follow.clash.common.formatString
|
||||||
|
import com.follow.clash.common.intent
|
||||||
|
import com.follow.clash.service.IAckInterface
|
||||||
|
import com.follow.clash.service.ICallbackInterface
|
||||||
|
import com.follow.clash.service.IEventInterface
|
||||||
|
import com.follow.clash.service.IRemoteInterface
|
||||||
|
import com.follow.clash.service.IResultInterface
|
||||||
|
import com.follow.clash.service.RemoteService
|
||||||
|
import com.follow.clash.service.models.NotificationParams
|
||||||
|
import com.follow.clash.service.models.VpnOptions
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
import kotlin.coroutines.resumeWithException
|
||||||
|
|
||||||
|
object Service {
|
||||||
|
private val delegate by lazy {
|
||||||
|
ServiceDelegate<IRemoteInterface>(
|
||||||
|
RemoteService::class.intent, ::handleServiceDisconnected
|
||||||
|
) {
|
||||||
|
IRemoteInterface.Stub.asInterface(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var onServiceDisconnected: ((String) -> Unit)? = null
|
||||||
|
|
||||||
|
private fun handleServiceDisconnected(message: String) {
|
||||||
|
onServiceDisconnected?.let {
|
||||||
|
it(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun bind() {
|
||||||
|
delegate.bind()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unbind() {
|
||||||
|
delegate.unbind()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun invokeAction(data: String, cb: (result: String) -> Unit): Result<Unit> {
|
||||||
|
val res = mutableListOf<ByteArray>()
|
||||||
|
return delegate.useService {
|
||||||
|
it.invokeAction(
|
||||||
|
data, object : ICallbackInterface.Stub() {
|
||||||
|
override fun onResult(
|
||||||
|
result: ByteArray?, isSuccess: Boolean, ack: IAckInterface?
|
||||||
|
) {
|
||||||
|
res.add(result ?: byteArrayOf())
|
||||||
|
ack?.onAck()
|
||||||
|
if (isSuccess) {
|
||||||
|
cb(res.formatString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setEventListener(
|
||||||
|
cb: ((result: String?) -> Unit)?
|
||||||
|
): Result<Unit> {
|
||||||
|
val results = HashMap<String, MutableList<ByteArray>>()
|
||||||
|
return delegate.useService {
|
||||||
|
it.setEventListener(
|
||||||
|
when (cb != null) {
|
||||||
|
true -> object : IEventInterface.Stub() {
|
||||||
|
override fun onEvent(
|
||||||
|
id: String, data: ByteArray?, isSuccess: Boolean, ack: IAckInterface?
|
||||||
|
) {
|
||||||
|
if (results[id] == null) {
|
||||||
|
results[id] = mutableListOf()
|
||||||
|
}
|
||||||
|
results[id]?.add(data ?: byteArrayOf())
|
||||||
|
ack?.onAck()
|
||||||
|
if (isSuccess) {
|
||||||
|
cb(results[id]?.formatString())
|
||||||
|
results.remove(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false -> null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateNotificationParams(
|
||||||
|
params: NotificationParams
|
||||||
|
): Result<Unit> {
|
||||||
|
return delegate.useService {
|
||||||
|
it.updateNotificationParams(params)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setCrashlytics(
|
||||||
|
enable: Boolean
|
||||||
|
): Result<Unit> {
|
||||||
|
return delegate.useService {
|
||||||
|
it.setCrashlytics(enable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun awaitIResultInterface(
|
||||||
|
block: (IResultInterface) -> Unit
|
||||||
|
): Long = suspendCancellableCoroutine { continuation ->
|
||||||
|
val callback = object : IResultInterface.Stub() {
|
||||||
|
override fun onResult(time: Long) {
|
||||||
|
if (continuation.isActive) {
|
||||||
|
continuation.resume(time)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
block(callback)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (continuation.isActive) {
|
||||||
|
continuation.resumeWithException(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
suspend fun startService(options: VpnOptions, runTime: Long): Long {
|
||||||
|
return delegate.useService {
|
||||||
|
awaitIResultInterface { callback ->
|
||||||
|
it.startService(options, runTime, callback)
|
||||||
|
}
|
||||||
|
}.getOrNull() ?: 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun stopService(): Long {
|
||||||
|
return delegate.useService {
|
||||||
|
awaitIResultInterface { callback ->
|
||||||
|
it.stopService(callback)
|
||||||
|
}
|
||||||
|
}.getOrNull() ?: 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getRunTime(): Long {
|
||||||
|
return delegate.useService {
|
||||||
|
it.runTime
|
||||||
|
}.getOrNull() ?: 0L
|
||||||
|
}
|
||||||
|
}
|
||||||
161
android/app/src/main/kotlin/com/follow/clash/State.kt
Normal file
161
android/app/src/main/kotlin/com/follow/clash/State.kt
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
package com.follow.clash
|
||||||
|
|
||||||
|
import com.follow.clash.common.GlobalState
|
||||||
|
import com.follow.clash.plugins.AppPlugin
|
||||||
|
import com.follow.clash.plugins.ServicePlugin
|
||||||
|
import com.follow.clash.plugins.TilePlugin
|
||||||
|
import io.flutter.FlutterInjector
|
||||||
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
import io.flutter.embedding.engine.dart.DartExecutor
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
enum class RunState {
|
||||||
|
START, PENDING, STOP
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
object State {
|
||||||
|
|
||||||
|
val runLock = Mutex()
|
||||||
|
|
||||||
|
var runTime: Long = 0
|
||||||
|
|
||||||
|
val runStateFlow: MutableStateFlow<RunState> = MutableStateFlow(RunState.STOP)
|
||||||
|
|
||||||
|
var flutterEngine: FlutterEngine? = null
|
||||||
|
var serviceFlutterEngine: FlutterEngine? = null
|
||||||
|
|
||||||
|
val appPlugin: AppPlugin?
|
||||||
|
get() = flutterEngine?.plugin<AppPlugin>() ?: serviceFlutterEngine?.plugin<AppPlugin>()
|
||||||
|
|
||||||
|
val servicePlugin: ServicePlugin?
|
||||||
|
get() = flutterEngine?.plugin<ServicePlugin>()
|
||||||
|
?: serviceFlutterEngine?.plugin<ServicePlugin>()
|
||||||
|
|
||||||
|
val tilePlugin: TilePlugin?
|
||||||
|
get() = flutterEngine?.plugin<TilePlugin>() ?: serviceFlutterEngine?.plugin<TilePlugin>()
|
||||||
|
|
||||||
|
suspend fun handleToggleAction() {
|
||||||
|
var action: (suspend () -> Unit)?
|
||||||
|
runLock.withLock {
|
||||||
|
action = when (runStateFlow.value) {
|
||||||
|
RunState.PENDING -> null
|
||||||
|
RunState.START -> ::handleStopServiceAction
|
||||||
|
RunState.STOP -> ::handleStartServiceAction
|
||||||
|
}
|
||||||
|
}
|
||||||
|
action?.invoke()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun handleSyncState() {
|
||||||
|
runLock.withLock {
|
||||||
|
Service.bind()
|
||||||
|
runTime = Service.getRunTime()
|
||||||
|
val runState = when (runTime == 0L) {
|
||||||
|
true -> RunState.STOP
|
||||||
|
false -> RunState.START
|
||||||
|
}
|
||||||
|
runStateFlow.tryEmit(runState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun handleStartServiceAction() {
|
||||||
|
tilePlugin?.handleStart()
|
||||||
|
if (flutterEngine != null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
startServiceWithEngine()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleStopServiceAction() {
|
||||||
|
tilePlugin?.handleStop()
|
||||||
|
if (flutterEngine != null || serviceFlutterEngine != null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handleStopService()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleStartService() {
|
||||||
|
if (appPlugin != null) {
|
||||||
|
appPlugin?.requestNotificationsPermission {
|
||||||
|
startService()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
startService()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun destroyServiceEngine() {
|
||||||
|
runLock.withLock {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
runCatching {
|
||||||
|
serviceFlutterEngine?.destroy()
|
||||||
|
serviceFlutterEngine = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun startServiceWithEngine() {
|
||||||
|
runLock.withLock {
|
||||||
|
if (serviceFlutterEngine != null || runStateFlow.value == RunState.PENDING || runStateFlow.value == RunState.START) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
serviceFlutterEngine = FlutterEngine(GlobalState.application)
|
||||||
|
serviceFlutterEngine?.plugins?.add(ServicePlugin())
|
||||||
|
serviceFlutterEngine?.plugins?.add(AppPlugin())
|
||||||
|
serviceFlutterEngine?.plugins?.add(TilePlugin())
|
||||||
|
val dartEntrypoint = DartExecutor.DartEntrypoint(
|
||||||
|
FlutterInjector.instance().flutterLoader().findAppBundlePath(), "_service"
|
||||||
|
)
|
||||||
|
serviceFlutterEngine?.dartExecutor?.executeDartEntrypoint(dartEntrypoint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startService() {
|
||||||
|
GlobalState.launch {
|
||||||
|
runLock.withLock {
|
||||||
|
if (runStateFlow.value == RunState.PENDING || runStateFlow.value == RunState.START) {
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
runStateFlow.tryEmit(RunState.PENDING)
|
||||||
|
if (servicePlugin == null) {
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val options = servicePlugin?.handleGetVpnOptions()
|
||||||
|
if (options == null) {
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
appPlugin?.prepare(options.enable) {
|
||||||
|
runTime = Service.startService(options, runTime)
|
||||||
|
runStateFlow.tryEmit(RunState.START)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleStopService() {
|
||||||
|
GlobalState.launch {
|
||||||
|
runLock.withLock {
|
||||||
|
if (runStateFlow.value == RunState.PENDING || runStateFlow.value == RunState.STOP) {
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
runStateFlow.tryEmit(RunState.PENDING)
|
||||||
|
runTime = Service.stopService()
|
||||||
|
runStateFlow.tryEmit(RunState.STOP)
|
||||||
|
}
|
||||||
|
destroyServiceEngine()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -2,22 +2,32 @@ package com.follow.clash
|
|||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import com.follow.clash.extensions.wrapAction
|
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() {
|
class TempActivity : Activity(),
|
||||||
|
CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
when (intent.action) {
|
when (intent.action) {
|
||||||
wrapAction("START") -> {
|
QuickAction.START.action -> {
|
||||||
GlobalState.handleStart()
|
launch {
|
||||||
|
State.handleStartServiceAction()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
wrapAction("STOP") -> {
|
QuickAction.STOP.action -> {
|
||||||
GlobalState.handleStop()
|
State.handleStopServiceAction()
|
||||||
}
|
}
|
||||||
|
|
||||||
wrapAction("CHANGE") -> {
|
QuickAction.TOGGLE.action -> {
|
||||||
GlobalState.handleToggle()
|
launch {
|
||||||
|
State.handleToggleAction()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
finishAndRemoveTask()
|
finishAndRemoveTask()
|
||||||
|
|||||||
61
android/app/src/main/kotlin/com/follow/clash/TileService.kt
Normal file
61
android/app/src/main/kotlin/com/follow/clash/TileService.kt
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
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
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class TileService : 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStopListening() {
|
||||||
|
scope?.cancel()
|
||||||
|
super.onStopListening()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
package com.follow.clash.extensions
|
|
||||||
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.graphics.Bitmap
|
|
||||||
import android.graphics.drawable.Drawable
|
|
||||||
import android.net.ConnectivityManager
|
|
||||||
import android.net.Network
|
|
||||||
import android.os.Build
|
|
||||||
import android.system.OsConstants.IPPROTO_TCP
|
|
||||||
import android.system.OsConstants.IPPROTO_UDP
|
|
||||||
import android.util.Base64
|
|
||||||
import androidx.core.graphics.drawable.toBitmap
|
|
||||||
import com.follow.clash.TempActivity
|
|
||||||
import com.follow.clash.models.CIDR
|
|
||||||
import com.follow.clash.models.Metadata
|
|
||||||
import com.follow.clash.models.VpnOptions
|
|
||||||
import io.flutter.plugin.common.MethodChannel
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import java.io.ByteArrayOutputStream
|
|
||||||
import java.net.Inet4Address
|
|
||||||
import java.net.Inet6Address
|
|
||||||
import java.net.InetAddress
|
|
||||||
import java.util.concurrent.locks.ReentrantLock
|
|
||||||
import kotlin.coroutines.resume
|
|
||||||
import kotlin.coroutines.suspendCoroutine
|
|
||||||
|
|
||||||
suspend fun Drawable.getBase64(): String {
|
|
||||||
val drawable = this
|
|
||||||
return withContext(Dispatchers.IO) {
|
|
||||||
val bitmap = drawable.toBitmap()
|
|
||||||
val byteArrayOutputStream = ByteArrayOutputStream()
|
|
||||||
bitmap.compress(Bitmap.CompressFormat.PNG, 100, byteArrayOutputStream)
|
|
||||||
Base64.encodeToString(byteArrayOutputStream.toByteArray(), Base64.NO_WRAP)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun Metadata.getProtocol(): Int? {
|
|
||||||
if (network.startsWith("tcp")) return IPPROTO_TCP
|
|
||||||
if (network.startsWith("udp")) return IPPROTO_UDP
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun VpnOptions.getIpv4RouteAddress(): List<CIDR> {
|
|
||||||
return routeAddress.filter {
|
|
||||||
it.isIpv4()
|
|
||||||
}.map {
|
|
||||||
it.toCIDR()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun VpnOptions.getIpv6RouteAddress(): List<CIDR> {
|
|
||||||
return routeAddress.filter {
|
|
||||||
it.isIpv6()
|
|
||||||
}.map {
|
|
||||||
it.toCIDR()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun String.isIpv4(): Boolean {
|
|
||||||
val parts = split("/")
|
|
||||||
if (parts.size != 2) {
|
|
||||||
throw IllegalArgumentException("Invalid CIDR format")
|
|
||||||
}
|
|
||||||
val address = InetAddress.getByName(parts[0])
|
|
||||||
return address.address.size == 4
|
|
||||||
}
|
|
||||||
|
|
||||||
fun String.isIpv6(): Boolean {
|
|
||||||
val parts = split("/")
|
|
||||||
if (parts.size != 2) {
|
|
||||||
throw IllegalArgumentException("Invalid CIDR format")
|
|
||||||
}
|
|
||||||
val address = InetAddress.getByName(parts[0])
|
|
||||||
return address.address.size == 16
|
|
||||||
}
|
|
||||||
|
|
||||||
fun String.toCIDR(): CIDR {
|
|
||||||
val parts = split("/")
|
|
||||||
if (parts.size != 2) {
|
|
||||||
throw IllegalArgumentException("Invalid CIDR format")
|
|
||||||
}
|
|
||||||
val ipAddress = parts[0]
|
|
||||||
val prefixLength = parts[1].toIntOrNull()
|
|
||||||
?: throw IllegalArgumentException("Invalid prefix length")
|
|
||||||
|
|
||||||
val address = InetAddress.getByName(ipAddress)
|
|
||||||
|
|
||||||
val maxPrefix = if (address.address.size == 4) 32 else 128
|
|
||||||
if (prefixLength < 0 || prefixLength > maxPrefix) {
|
|
||||||
throw IllegalArgumentException("Invalid prefix length for IP version")
|
|
||||||
}
|
|
||||||
|
|
||||||
return CIDR(address, prefixLength)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun ConnectivityManager.resolveDns(network: Network?): List<String> {
|
|
||||||
val properties = getLinkProperties(network) ?: return listOf()
|
|
||||||
return properties.dnsServers.map { it.asSocketAddressText(53) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun InetAddress.asSocketAddressText(port: Int): String {
|
|
||||||
return when (this) {
|
|
||||||
is Inet6Address ->
|
|
||||||
"[${numericToTextFormat(this.address)}]:$port"
|
|
||||||
|
|
||||||
is Inet4Address ->
|
|
||||||
"${this.hostAddress}:$port"
|
|
||||||
|
|
||||||
else -> throw IllegalArgumentException("Unsupported Inet type ${this.javaClass}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun Context.wrapAction(action: String): String {
|
|
||||||
return "${this.packageName}.action.$action"
|
|
||||||
}
|
|
||||||
|
|
||||||
fun Context.getActionIntent(action: String): Intent {
|
|
||||||
val actionIntent = Intent(this, TempActivity::class.java)
|
|
||||||
actionIntent.action = wrapAction(action)
|
|
||||||
return actionIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun Context.getActionPendingIntent(action: String): PendingIntent {
|
|
||||||
return if (Build.VERSION.SDK_INT >= 31) {
|
|
||||||
PendingIntent.getActivity(
|
|
||||||
this,
|
|
||||||
0,
|
|
||||||
getActionIntent(action),
|
|
||||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
PendingIntent.getActivity(
|
|
||||||
this,
|
|
||||||
0,
|
|
||||||
getActionIntent(action),
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun numericToTextFormat(src: ByteArray): String {
|
|
||||||
val sb = StringBuilder(39)
|
|
||||||
for (i in 0 until 8) {
|
|
||||||
sb.append(
|
|
||||||
Integer.toHexString(
|
|
||||||
src[i shl 1].toInt() shl 8 and 0xff00
|
|
||||||
or (src[(i shl 1) + 1].toInt() and 0xff)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if (i < 7) {
|
|
||||||
sb.append(":")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sb.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun <T> MethodChannel.awaitResult(
|
|
||||||
method: String,
|
|
||||||
arguments: Any? = null
|
|
||||||
): T? = withContext(Dispatchers.Main) { // 切换到主线程
|
|
||||||
suspendCoroutine { continuation ->
|
|
||||||
invokeMethod(method, arguments, object : MethodChannel.Result {
|
|
||||||
override fun success(result: Any?) {
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
continuation.resume(result as T)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun error(code: String, message: String?, details: Any?) {
|
|
||||||
continuation.resume(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun notImplemented() {
|
|
||||||
continuation.resume(null)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun ReentrantLock.safeLock() {
|
|
||||||
if (this.isLocked) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.lock()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun ReentrantLock.safeUnlock() {
|
|
||||||
if (!this.isLocked) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.unlock()
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
package com.follow.clash.models
|
|
||||||
|
|
||||||
data class Process(
|
|
||||||
val id: String,
|
|
||||||
val metadata: Metadata,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class Metadata(
|
|
||||||
val network: String,
|
|
||||||
val sourceIP: String,
|
|
||||||
val sourcePort: Int,
|
|
||||||
val destinationIP: String,
|
|
||||||
val destinationPort: Int,
|
|
||||||
val host: String
|
|
||||||
)
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
package com.follow.clash.models
|
|
||||||
|
|
||||||
import java.net.InetAddress
|
|
||||||
|
|
||||||
enum class AccessControlMode {
|
|
||||||
acceptSelected, rejectSelected,
|
|
||||||
}
|
|
||||||
|
|
||||||
data class AccessControl(
|
|
||||||
val enable: Boolean,
|
|
||||||
val mode: AccessControlMode,
|
|
||||||
val acceptList: List<String>,
|
|
||||||
val rejectList: List<String>,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CIDR(val address: InetAddress, val prefixLength: Int)
|
|
||||||
|
|
||||||
data class VpnOptions(
|
|
||||||
val enable: Boolean,
|
|
||||||
val port: Int,
|
|
||||||
val accessControl: AccessControl,
|
|
||||||
val allowBypass: Boolean,
|
|
||||||
val systemProxy: Boolean,
|
|
||||||
val bypassDomain: List<String>,
|
|
||||||
val routeAddress: List<String>,
|
|
||||||
val ipv4Address: String,
|
|
||||||
val ipv6Address: String,
|
|
||||||
val dnsServerAddress: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class StartForegroundParams(
|
|
||||||
val title: String,
|
|
||||||
val content: String,
|
|
||||||
)
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.follow.clash.models
|
||||||
|
|
||||||
|
|
||||||
|
data class AppState(
|
||||||
|
val crashlytics: Boolean = true,
|
||||||
|
val currentProfileName: String = "FlClash",
|
||||||
|
val stopText: String = "Stop",
|
||||||
|
val onlyStatisticsProxy: Boolean = false,
|
||||||
|
)
|
||||||
@@ -13,17 +13,16 @@ import android.widget.Toast
|
|||||||
import androidx.core.app.ActivityCompat
|
import androidx.core.app.ActivityCompat
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.content.ContextCompat.getSystemService
|
import androidx.core.content.ContextCompat.getSystemService
|
||||||
import androidx.core.content.FileProvider
|
|
||||||
import androidx.core.content.pm.ShortcutInfoCompat
|
import androidx.core.content.pm.ShortcutInfoCompat
|
||||||
import androidx.core.content.pm.ShortcutManagerCompat
|
import androidx.core.content.pm.ShortcutManagerCompat
|
||||||
import androidx.core.graphics.drawable.IconCompat
|
import androidx.core.graphics.drawable.IconCompat
|
||||||
import com.android.tools.smali.dexlib2.dexbacked.DexBackedDexFile
|
import com.android.tools.smali.dexlib2.dexbacked.DexBackedDexFile
|
||||||
import com.follow.clash.FlClashApplication
|
|
||||||
import com.follow.clash.GlobalState
|
|
||||||
import com.follow.clash.R
|
import com.follow.clash.R
|
||||||
import com.follow.clash.extensions.awaitResult
|
import com.follow.clash.common.Components
|
||||||
import com.follow.clash.extensions.getActionIntent
|
import com.follow.clash.common.GlobalState
|
||||||
import com.follow.clash.extensions.getBase64
|
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.models.Package
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
@@ -44,15 +43,20 @@ import java.util.zip.ZipFile
|
|||||||
|
|
||||||
class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware {
|
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 activityRef: WeakReference<Activity>? = null
|
||||||
|
|
||||||
private lateinit var channel: MethodChannel
|
private lateinit var channel: MethodChannel
|
||||||
|
|
||||||
private lateinit var scope: CoroutineScope
|
private lateinit var scope: CoroutineScope
|
||||||
|
|
||||||
private var vpnCallBack: (() -> Unit)? = null
|
private var vpnPrepareCallback: (suspend () -> Unit)? = null
|
||||||
|
|
||||||
private val iconMap = mutableMapOf<String, String?>()
|
private var requestNotificationCallback: (() -> Unit)? = null
|
||||||
|
|
||||||
private val packages = mutableListOf<Package>()
|
private val packages = mutableListOf<Package>()
|
||||||
|
|
||||||
@@ -111,46 +115,8 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
|||||||
("(" + chinaAppPrefixList.joinToString("|").replace(".", "\\.") + ").*").toRegex()
|
("(" + chinaAppPrefixList.joinToString("|").replace(".", "\\.") + ").*").toRegex()
|
||||||
}
|
}
|
||||||
|
|
||||||
val VPN_PERMISSION_REQUEST_CODE = 1001
|
|
||||||
|
|
||||||
val NOTIFICATION_PERMISSION_REQUEST_CODE = 1002
|
|
||||||
|
|
||||||
private var isBlockNotification: Boolean = false
|
private var isBlockNotification: Boolean = false
|
||||||
|
|
||||||
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
|
|
||||||
scope = CoroutineScope(Dispatchers.Default)
|
|
||||||
channel = MethodChannel(flutterPluginBinding.binaryMessenger, "app")
|
|
||||||
channel.setMethodCallHandler(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initShortcuts(label: String) {
|
|
||||||
val shortcut = ShortcutInfoCompat.Builder(FlClashApplication.getAppContext(), "toggle")
|
|
||||||
.setShortLabel(label)
|
|
||||||
.setIcon(
|
|
||||||
IconCompat.createWithResource(
|
|
||||||
FlClashApplication.getAppContext(),
|
|
||||||
R.mipmap.ic_launcher_round
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.setIntent(FlClashApplication.getAppContext().getActionIntent("CHANGE"))
|
|
||||||
.build()
|
|
||||||
ShortcutManagerCompat.setDynamicShortcuts(
|
|
||||||
FlClashApplication.getAppContext(),
|
|
||||||
listOf(shortcut)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
|
||||||
channel.setMethodCallHandler(null)
|
|
||||||
scope.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun tip(message: String?) {
|
|
||||||
if (GlobalState.flutterEngine == null) {
|
|
||||||
Toast.makeText(FlClashApplication.getAppContext(), message, Toast.LENGTH_LONG).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onMethodCall(call: MethodCall, result: Result) {
|
override fun onMethodCall(call: MethodCall, result: Result) {
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
"moveTaskToBack" -> {
|
"moveTaskToBack" -> {
|
||||||
@@ -182,26 +148,7 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
|||||||
}
|
}
|
||||||
|
|
||||||
"getPackageIcon" -> {
|
"getPackageIcon" -> {
|
||||||
scope.launch {
|
handleGetPackageIcon(call, result)
|
||||||
val packageName = call.argument<String>("packageName")
|
|
||||||
if (packageName == null) {
|
|
||||||
result.success(null)
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
val packageIcon = getPackageIcon(packageName)
|
|
||||||
packageIcon.let {
|
|
||||||
if (it != null) {
|
|
||||||
result.success(it)
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
if (iconMap["default"] == null) {
|
|
||||||
iconMap["default"] =
|
|
||||||
FlClashApplication.getAppContext().packageManager?.defaultActivityIcon?.getBase64()
|
|
||||||
}
|
|
||||||
result.success(iconMap["default"])
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
"tip" -> {
|
"tip" -> {
|
||||||
@@ -210,56 +157,48 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
|||||||
result.success(true)
|
result.success(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
"openFile" -> {
|
|
||||||
val path = call.argument<String>("path")!!
|
|
||||||
openFile(path)
|
|
||||||
result.success(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
result.notImplemented()
|
result.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun openFile(path: String) {
|
private fun handleGetPackageIcon(call: MethodCall, result: Result) {
|
||||||
val file = File(path)
|
scope.launch {
|
||||||
val uri = FileProvider.getUriForFile(
|
val packageName = call.argument<String>("packageName")
|
||||||
FlClashApplication.getAppContext(),
|
if (packageName == null) {
|
||||||
"${FlClashApplication.getAppContext().packageName}.fileProvider",
|
result.success("")
|
||||||
file
|
return@launch
|
||||||
)
|
}
|
||||||
|
val path = GlobalState.application.packageManager.getPackageIconPath(packageName)
|
||||||
val intent = Intent(Intent.ACTION_VIEW).setDataAndType(
|
result.success(path)
|
||||||
uri,
|
|
||||||
"text/plain"
|
|
||||||
)
|
|
||||||
|
|
||||||
val flags =
|
|
||||||
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION
|
|
||||||
|
|
||||||
val resInfoList = FlClashApplication.getAppContext().packageManager.queryIntentActivities(
|
|
||||||
intent, PackageManager.MATCH_DEFAULT_ONLY
|
|
||||||
)
|
|
||||||
|
|
||||||
for (resolveInfo in resInfoList) {
|
|
||||||
val packageName = resolveInfo.activityInfo.packageName
|
|
||||||
FlClashApplication.getAppContext().grantUriPermission(
|
|
||||||
packageName,
|
|
||||||
uri,
|
|
||||||
flags
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
activityRef?.get()?.startActivity(intent)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
println(e)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun initShortcuts(label: String) {
|
||||||
|
val shortcut = with(ShortcutInfoCompat.Builder(GlobalState.application, "toggle")) {
|
||||||
|
setShortLabel(label)
|
||||||
|
setIcon(
|
||||||
|
IconCompat.createWithResource(
|
||||||
|
GlobalState.application,
|
||||||
|
R.mipmap.ic_launcher_round,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
setIntent(QuickAction.TOGGLE.quickIntent)
|
||||||
|
build()
|
||||||
|
}
|
||||||
|
ShortcutManagerCompat.setDynamicShortcuts(
|
||||||
|
GlobalState.application, listOf(shortcut)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tip(message: String?) {
|
||||||
|
Toast.makeText(GlobalState.application, message, Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
private fun updateExcludeFromRecents(value: Boolean?) {
|
private fun updateExcludeFromRecents(value: Boolean?) {
|
||||||
val am = getSystemService(FlClashApplication.getAppContext(), ActivityManager::class.java)
|
val am = getSystemService(GlobalState.application, ActivityManager::class.java)
|
||||||
val task = am?.appTasks?.firstOrNull {
|
val task = am?.appTasks?.firstOrNull {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
it.taskInfo.taskId == activityRef?.get()?.taskId
|
it.taskInfo.taskId == activityRef?.get()?.taskId
|
||||||
@@ -275,31 +214,18 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getPackageIcon(packageName: String): String? {
|
|
||||||
val packageManager = FlClashApplication.getAppContext().packageManager
|
|
||||||
if (iconMap[packageName] == null) {
|
|
||||||
iconMap[packageName] = try {
|
|
||||||
packageManager?.getApplicationIcon(packageName)?.getBase64()
|
|
||||||
} catch (_: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
return iconMap[packageName]
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getPackages(): List<Package> {
|
private fun getPackages(): List<Package> {
|
||||||
val packageManager = FlClashApplication.getAppContext().packageManager
|
val packageManager = GlobalState.application.packageManager
|
||||||
if (packages.isNotEmpty()) return packages
|
if (packages.isNotEmpty()) return packages
|
||||||
packageManager?.getInstalledPackages(PackageManager.GET_META_DATA or PackageManager.GET_PERMISSIONS)
|
packageManager?.getInstalledPackages(PackageManager.GET_META_DATA or PackageManager.GET_PERMISSIONS)
|
||||||
?.filter {
|
?.filter {
|
||||||
it.packageName != FlClashApplication.getAppContext().packageName || it.packageName == "android"
|
it.packageName != GlobalState.application.packageName && it.packageName != "android"
|
||||||
|
|
||||||
}?.map {
|
}?.map {
|
||||||
Package(
|
Package(
|
||||||
packageName = it.packageName,
|
packageName = it.packageName,
|
||||||
label = it.applicationInfo?.loadLabel(packageManager).toString(),
|
label = it.applicationInfo?.loadLabel(packageManager).toString(),
|
||||||
system = (it.applicationInfo?.flags?.and(ApplicationInfo.FLAG_SYSTEM)) == 1,
|
system = (it.applicationInfo?.flags?.and(ApplicationInfo.FLAG_SYSTEM)) != 0,
|
||||||
lastUpdateTime = it.lastUpdateTime,
|
lastUpdateTime = it.lastUpdateTime,
|
||||||
internet = it.requestedPermissions?.contains(Manifest.permission.INTERNET) == true
|
internet = it.requestedPermissions?.contains(Manifest.permission.INTERNET) == true
|
||||||
)
|
)
|
||||||
@@ -321,52 +247,66 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun requestVpnPermission(callBack: () -> Unit) {
|
fun requestNotificationsPermission(callBack: () -> Unit) {
|
||||||
vpnCallBack = callBack
|
requestNotificationCallback = callBack
|
||||||
val intent = VpnService.prepare(FlClashApplication.getAppContext())
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
val permission = ContextCompat.checkSelfPermission(
|
||||||
|
GlobalState.application, Manifest.permission.POST_NOTIFICATIONS
|
||||||
|
)
|
||||||
|
if (permission == PackageManager.PERMISSION_GRANTED || isBlockNotification) {
|
||||||
|
invokeRequestNotificationCallback()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
activityRef?.get()?.let {
|
||||||
|
ActivityCompat.requestPermissions(
|
||||||
|
it,
|
||||||
|
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||||
|
NOTIFICATION_PERMISSION_REQUEST_CODE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
invokeRequestNotificationCallback()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
fun invokeRequestNotificationCallback() {
|
||||||
|
requestNotificationCallback?.invoke()
|
||||||
|
requestNotificationCallback = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun prepare(needPrepare: Boolean, callBack: (suspend () -> Unit)) {
|
||||||
|
vpnPrepareCallback = callBack
|
||||||
|
if (!needPrepare) {
|
||||||
|
invokeVpnPrepareCallback()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val intent = VpnService.prepare(GlobalState.application)
|
||||||
if (intent != null) {
|
if (intent != null) {
|
||||||
activityRef?.get()?.startActivityForResult(intent, VPN_PERMISSION_REQUEST_CODE)
|
activityRef?.get()?.startActivityForResult(intent, VPN_PERMISSION_REQUEST_CODE)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
vpnCallBack?.invoke()
|
invokeVpnPrepareCallback()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun requestNotificationsPermission() {
|
fun invokeVpnPrepareCallback() {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
GlobalState.launch {
|
||||||
val permission = ContextCompat.checkSelfPermission(
|
vpnPrepareCallback?.invoke()
|
||||||
FlClashApplication.getAppContext(),
|
vpnPrepareCallback = null
|
||||||
Manifest.permission.POST_NOTIFICATIONS
|
|
||||||
)
|
|
||||||
if (permission != PackageManager.PERMISSION_GRANTED) {
|
|
||||||
if (isBlockNotification) return
|
|
||||||
if (activityRef?.get() == null) return
|
|
||||||
activityRef?.get()?.let {
|
|
||||||
ActivityCompat.requestPermissions(
|
|
||||||
it,
|
|
||||||
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
|
||||||
NOTIFICATION_PERMISSION_REQUEST_CODE
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getText(text: String): String? {
|
|
||||||
return withContext(Dispatchers.Default) {
|
|
||||||
channel.awaitResult<String>("getText", text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
private fun isChinaPackage(packageName: String): Boolean {
|
private fun isChinaPackage(packageName: String): Boolean {
|
||||||
val packageManager = FlClashApplication.getAppContext().packageManager ?: return false
|
val packageManager = GlobalState.application.packageManager ?: return false
|
||||||
skipPrefixList.forEach {
|
skipPrefixList.forEach {
|
||||||
if (packageName == it || packageName.startsWith("$it.")) return false
|
if (packageName == it || packageName.startsWith("$it.")) return false
|
||||||
}
|
}
|
||||||
val packageManagerFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
val packageManagerFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
PackageManager.MATCH_UNINSTALLED_PACKAGES or PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES or PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS
|
PackageManager.MATCH_UNINSTALLED_PACKAGES or PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES or PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS
|
||||||
} else {
|
} else {
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
PackageManager.GET_UNINSTALLED_PACKAGES or PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES or PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS
|
PackageManager.GET_UNINSTALLED_PACKAGES or PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES or PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS
|
||||||
}
|
}
|
||||||
if (packageName.matches(chinaAppRegex)) {
|
if (packageName.matches(chinaAppRegex)) {
|
||||||
@@ -375,8 +315,7 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
|||||||
try {
|
try {
|
||||||
val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
packageManager.getPackageInfo(
|
packageManager.getPackageInfo(
|
||||||
packageName,
|
packageName, PackageManager.PackageInfoFlags.of(packageManagerFlags.toLong())
|
||||||
PackageManager.PackageInfoFlags.of(packageManagerFlags.toLong())
|
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
packageManager.getPackageInfo(
|
packageManager.getPackageInfo(
|
||||||
@@ -427,6 +366,18 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
|
scope = CoroutineScope(Dispatchers.Default)
|
||||||
|
channel =
|
||||||
|
MethodChannel(flutterPluginBinding.binaryMessenger, "${Components.PACKAGE_NAME}/app")
|
||||||
|
channel.setMethodCallHandler(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
|
channel.setMethodCallHandler(null)
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||||
activityRef = WeakReference(binding.activity)
|
activityRef = WeakReference(binding.activity)
|
||||||
binding.addActivityResultListener(::onActivityResult)
|
binding.addActivityResultListener(::onActivityResult)
|
||||||
@@ -449,21 +400,19 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
|
|||||||
private fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
|
private fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
|
||||||
if (requestCode == VPN_PERMISSION_REQUEST_CODE) {
|
if (requestCode == VPN_PERMISSION_REQUEST_CODE) {
|
||||||
if (resultCode == FlutterActivity.RESULT_OK) {
|
if (resultCode == FlutterActivity.RESULT_OK) {
|
||||||
GlobalState.initServiceEngine()
|
invokeVpnPrepareCallback()
|
||||||
vpnCallBack?.invoke()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onRequestPermissionsResultListener(
|
private fun onRequestPermissionsResultListener(
|
||||||
requestCode: Int,
|
requestCode: Int, permissions: Array<String>, grantResults: IntArray
|
||||||
permissions: Array<String>,
|
|
||||||
grantResults: IntArray
|
|
||||||
): Boolean {
|
): Boolean {
|
||||||
if (requestCode == NOTIFICATION_PERMISSION_REQUEST_CODE) {
|
if (requestCode == NOTIFICATION_PERMISSION_REQUEST_CODE) {
|
||||||
isBlockNotification = true
|
isBlockNotification = true
|
||||||
}
|
}
|
||||||
|
invokeRequestNotificationCallback()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,34 @@
|
|||||||
package com.follow.clash.plugins
|
package com.follow.clash.plugins
|
||||||
|
|
||||||
import com.follow.clash.GlobalState
|
import com.follow.clash.RunState
|
||||||
import com.follow.clash.models.VpnOptions
|
import com.follow.clash.Service
|
||||||
|
import com.follow.clash.State
|
||||||
|
import com.follow.clash.awaitResult
|
||||||
|
import com.follow.clash.common.Components
|
||||||
|
import com.follow.clash.common.GlobalState
|
||||||
|
import com.follow.clash.invokeMethodOnMainThread
|
||||||
|
import com.follow.clash.models.AppState
|
||||||
|
import com.follow.clash.service.models.NotificationParams
|
||||||
|
import com.follow.clash.service.models.VpnOptions
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||||
import io.flutter.plugin.common.MethodCall
|
import io.flutter.plugin.common.MethodCall
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
|
import kotlinx.coroutines.sync.withPermit
|
||||||
|
|
||||||
|
class ServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
|
||||||
data object ServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) {
|
||||||
|
|
||||||
private lateinit var flutterMethodChannel: MethodChannel
|
private lateinit var flutterMethodChannel: MethodChannel
|
||||||
|
|
||||||
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
|
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
flutterMethodChannel = MethodChannel(flutterPluginBinding.binaryMessenger, "service")
|
flutterMethodChannel = MethodChannel(
|
||||||
|
flutterPluginBinding.binaryMessenger, "${Components.PACKAGE_NAME}/service"
|
||||||
|
)
|
||||||
flutterMethodChannel.setMethodCallHandler(this)
|
flutterMethodChannel.setMethodCallHandler(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,28 +37,32 @@ data object ServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) = when (call.method) {
|
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) = when (call.method) {
|
||||||
"startVpn" -> {
|
|
||||||
val data = call.argument<String>("data")
|
|
||||||
val options = Gson().fromJson(data, VpnOptions::class.java)
|
|
||||||
GlobalState.getCurrentVPNPlugin()?.handleStart(options)
|
|
||||||
result.success(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
"stopVpn" -> {
|
|
||||||
GlobalState.getCurrentVPNPlugin()?.handleStop()
|
|
||||||
result.success(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
"init" -> {
|
"init" -> {
|
||||||
GlobalState.getCurrentAppPlugin()
|
handleInit(call, result)
|
||||||
?.requestNotificationsPermission()
|
|
||||||
GlobalState.initServiceEngine()
|
|
||||||
result.success(true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
"destroy" -> {
|
"shutdown" -> {
|
||||||
handleDestroy()
|
handleShutdown(result)
|
||||||
result.success(true)
|
}
|
||||||
|
|
||||||
|
"invokeAction" -> {
|
||||||
|
handleInvokeAction(call, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
"getRunTime" -> {
|
||||||
|
handleGetRunTime(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
"syncState" -> {
|
||||||
|
handleSyncState(call, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
"start" -> {
|
||||||
|
handleStart(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
"stop" -> {
|
||||||
|
handleStop(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
@@ -51,7 +70,91 @@ data object ServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleDestroy() {
|
private fun handleInvokeAction(call: MethodCall, result: MethodChannel.Result) {
|
||||||
GlobalState.destroyServiceEngine()
|
launch {
|
||||||
|
val data = call.arguments<String>()!!
|
||||||
|
Service.invokeAction(data) {
|
||||||
|
result.success(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleShutdown(result: MethodChannel.Result) {
|
||||||
|
Service.unbind()
|
||||||
|
result.success(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleStart(result: MethodChannel.Result) {
|
||||||
|
State.handleStartService()
|
||||||
|
result.success(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleStop(result: MethodChannel.Result) {
|
||||||
|
State.handleStopService()
|
||||||
|
result.success(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun handleGetVpnOptions(): VpnOptions? {
|
||||||
|
val res = flutterMethodChannel.awaitResult<String>("getVpnOptions", null)
|
||||||
|
return Gson().fromJson(res, VpnOptions::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
val semaphore = Semaphore(10)
|
||||||
|
|
||||||
|
fun handleSendEvent(value: String?) {
|
||||||
|
launch(Dispatchers.Main) {
|
||||||
|
semaphore.withPermit {
|
||||||
|
flutterMethodChannel.invokeMethod("event", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onServiceDisconnected(message: String) {
|
||||||
|
State.runStateFlow.tryEmit(RunState.STOP)
|
||||||
|
flutterMethodChannel.invokeMethodOnMainThread<Any>("crash", message)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleSyncState(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
val data = call.arguments<String>()!!
|
||||||
|
val params = Gson().fromJson(data, AppState::class.java)
|
||||||
|
GlobalState.setCrashlytics(params.crashlytics)
|
||||||
|
launch {
|
||||||
|
Service.updateNotificationParams(
|
||||||
|
NotificationParams(
|
||||||
|
title = params.currentProfileName,
|
||||||
|
stopText = params.stopText,
|
||||||
|
onlyStatisticsProxy = params.onlyStatisticsProxy
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Service.setCrashlytics(params.crashlytics)
|
||||||
|
result.success("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleInit(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
Service.bind()
|
||||||
|
launch {
|
||||||
|
val needSetEventListener = call.arguments<Boolean>() ?: false
|
||||||
|
when (needSetEventListener) {
|
||||||
|
true -> Service.setEventListener {
|
||||||
|
handleSendEvent(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
false -> Service.setEventListener(null)
|
||||||
|
}.onSuccess {
|
||||||
|
result.success("")
|
||||||
|
}.onFailure {
|
||||||
|
result.success(it.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
Service.onServiceDisconnected = ::onServiceDisconnected
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleGetRunTime(result: MethodChannel.Result) {
|
||||||
|
launch {
|
||||||
|
State.handleSyncState()
|
||||||
|
result.success(State.runTime)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.follow.clash.plugins
|
package com.follow.clash.plugins
|
||||||
|
|
||||||
|
import com.follow.clash.common.Components
|
||||||
|
import com.follow.clash.invokeMethodOnMainThread
|
||||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||||
import io.flutter.plugin.common.MethodCall
|
import io.flutter.plugin.common.MethodCall
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
@@ -9,25 +11,21 @@ class TilePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
|||||||
private lateinit var channel: MethodChannel
|
private lateinit var channel: MethodChannel
|
||||||
|
|
||||||
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
|
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
channel = MethodChannel(flutterPluginBinding.binaryMessenger, "tile")
|
channel =
|
||||||
|
MethodChannel(flutterPluginBinding.binaryMessenger, "${Components.PACKAGE_NAME}/tile")
|
||||||
channel.setMethodCallHandler(this)
|
channel.setMethodCallHandler(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
handleDetached()
|
|
||||||
channel.setMethodCallHandler(null)
|
channel.setMethodCallHandler(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun handleStart() {
|
fun handleStart() {
|
||||||
channel.invokeMethod("start", null)
|
channel.invokeMethodOnMainThread<Any>("start", null)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun handleStop() {
|
fun handleStop() {
|
||||||
channel.invokeMethod("stop", null)
|
channel.invokeMethodOnMainThread<Any>("stop", null)
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleDetached() {
|
|
||||||
channel.invokeMethod("detached", null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,268 +0,0 @@
|
|||||||
package com.follow.clash.plugins
|
|
||||||
|
|
||||||
import android.content.ComponentName
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.ServiceConnection
|
|
||||||
import android.net.ConnectivityManager
|
|
||||||
import android.net.Network
|
|
||||||
import android.net.NetworkCapabilities
|
|
||||||
import android.net.NetworkRequest
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.IBinder
|
|
||||||
import androidx.core.content.getSystemService
|
|
||||||
import com.follow.clash.FlClashApplication
|
|
||||||
import com.follow.clash.GlobalState
|
|
||||||
import com.follow.clash.RunState
|
|
||||||
import com.follow.clash.core.Core
|
|
||||||
import com.follow.clash.extensions.awaitResult
|
|
||||||
import com.follow.clash.extensions.resolveDns
|
|
||||||
import com.follow.clash.models.StartForegroundParams
|
|
||||||
import com.follow.clash.models.VpnOptions
|
|
||||||
import com.follow.clash.services.BaseServiceInterface
|
|
||||||
import com.follow.clash.services.FlClashService
|
|
||||||
import com.follow.clash.services.FlClashVpnService
|
|
||||||
import com.google.gson.Gson
|
|
||||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
|
||||||
import io.flutter.plugin.common.MethodCall
|
|
||||||
import io.flutter.plugin.common.MethodChannel
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.isActive
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import java.net.InetSocketAddress
|
|
||||||
import kotlin.concurrent.withLock
|
|
||||||
|
|
||||||
data object VpnPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
|
||||||
private lateinit var flutterMethodChannel: MethodChannel
|
|
||||||
private var flClashService: BaseServiceInterface? = null
|
|
||||||
private var options: VpnOptions? = null
|
|
||||||
private var isBind: Boolean = false
|
|
||||||
private lateinit var scope: CoroutineScope
|
|
||||||
private var lastStartForegroundParams: StartForegroundParams? = null
|
|
||||||
private var timerJob: Job? = null
|
|
||||||
private val uidPageNameMap = mutableMapOf<Int, String>()
|
|
||||||
|
|
||||||
private val connectivity by lazy {
|
|
||||||
FlClashApplication.getAppContext().getSystemService<ConnectivityManager>()
|
|
||||||
}
|
|
||||||
|
|
||||||
private val connection = object : ServiceConnection {
|
|
||||||
override fun onServiceConnected(className: ComponentName, service: IBinder) {
|
|
||||||
isBind = true
|
|
||||||
flClashService = when (service) {
|
|
||||||
is FlClashVpnService.LocalBinder -> service.getService()
|
|
||||||
is FlClashService.LocalBinder -> service.getService()
|
|
||||||
else -> throw Exception("invalid binder")
|
|
||||||
}
|
|
||||||
handleStartService()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onServiceDisconnected(arg: ComponentName) {
|
|
||||||
isBind = false
|
|
||||||
flClashService = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
|
|
||||||
scope = CoroutineScope(Dispatchers.Default)
|
|
||||||
scope.launch {
|
|
||||||
registerNetworkCallback()
|
|
||||||
}
|
|
||||||
flutterMethodChannel = MethodChannel(flutterPluginBinding.binaryMessenger, "vpn")
|
|
||||||
flutterMethodChannel.setMethodCallHandler(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDetachedFromEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
|
|
||||||
unRegisterNetworkCallback()
|
|
||||||
flutterMethodChannel.setMethodCallHandler(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
|
||||||
when (call.method) {
|
|
||||||
"start" -> {
|
|
||||||
val data = call.argument<String>("data")
|
|
||||||
result.success(handleStart(Gson().fromJson(data, VpnOptions::class.java)))
|
|
||||||
}
|
|
||||||
|
|
||||||
"stop" -> {
|
|
||||||
handleStop()
|
|
||||||
result.success(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
result.notImplemented()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun handleStart(options: VpnOptions): Boolean {
|
|
||||||
if (options.enable != this.options?.enable) {
|
|
||||||
this.flClashService = null
|
|
||||||
}
|
|
||||||
this.options = options
|
|
||||||
when (options.enable) {
|
|
||||||
true -> handleStartVpn()
|
|
||||||
false -> handleStartService()
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleStartVpn() {
|
|
||||||
GlobalState.getCurrentAppPlugin()?.requestVpnPermission {
|
|
||||||
handleStartService()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun requestGc() {
|
|
||||||
flutterMethodChannel.invokeMethod("gc", null)
|
|
||||||
}
|
|
||||||
|
|
||||||
val networks = mutableSetOf<Network>()
|
|
||||||
|
|
||||||
fun onUpdateNetwork() {
|
|
||||||
val dns = networks.flatMap { network ->
|
|
||||||
connectivity?.resolveDns(network) ?: emptyList()
|
|
||||||
}.toSet().joinToString(",")
|
|
||||||
scope.launch {
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
flutterMethodChannel.invokeMethod("dnsChanged", dns)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val callback = object : ConnectivityManager.NetworkCallback() {
|
|
||||||
override fun onAvailable(network: Network) {
|
|
||||||
networks.add(network)
|
|
||||||
onUpdateNetwork()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onLost(network: Network) {
|
|
||||||
networks.remove(network)
|
|
||||||
onUpdateNetwork()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val request = NetworkRequest.Builder().apply {
|
|
||||||
addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
|
|
||||||
addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
|
||||||
addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED)
|
|
||||||
}.build()
|
|
||||||
|
|
||||||
private fun registerNetworkCallback() {
|
|
||||||
networks.clear()
|
|
||||||
connectivity?.registerNetworkCallback(request, callback)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun unRegisterNetworkCallback() {
|
|
||||||
connectivity?.unregisterNetworkCallback(callback)
|
|
||||||
networks.clear()
|
|
||||||
onUpdateNetwork()
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun startForeground() {
|
|
||||||
GlobalState.runLock.lock()
|
|
||||||
try {
|
|
||||||
if (GlobalState.runState.value != RunState.START) return
|
|
||||||
val data = flutterMethodChannel.awaitResult<String>("getStartForegroundParams")
|
|
||||||
val startForegroundParams = if (data != null) Gson().fromJson(
|
|
||||||
data, StartForegroundParams::class.java
|
|
||||||
) else StartForegroundParams(
|
|
||||||
title = "", content = ""
|
|
||||||
)
|
|
||||||
if (lastStartForegroundParams != startForegroundParams) {
|
|
||||||
lastStartForegroundParams = startForegroundParams
|
|
||||||
flClashService?.startForeground(
|
|
||||||
startForegroundParams.title,
|
|
||||||
startForegroundParams.content,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
GlobalState.runLock.unlock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun startForegroundJob() {
|
|
||||||
stopForegroundJob()
|
|
||||||
timerJob = CoroutineScope(Dispatchers.Main).launch {
|
|
||||||
while (isActive) {
|
|
||||||
startForeground()
|
|
||||||
delay(1000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun stopForegroundJob() {
|
|
||||||
timerJob?.cancel()
|
|
||||||
timerJob = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleStartService() {
|
|
||||||
if (flClashService == null) {
|
|
||||||
bindService()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
GlobalState.runLock.withLock {
|
|
||||||
if (GlobalState.runState.value == RunState.START) return
|
|
||||||
GlobalState.runState.value = RunState.START
|
|
||||||
val fd = flClashService?.start(options!!)
|
|
||||||
Core.startTun(
|
|
||||||
fd = fd ?: 0,
|
|
||||||
protect = this::protect,
|
|
||||||
resolverProcess = this::resolverProcess,
|
|
||||||
)
|
|
||||||
startForegroundJob()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun protect(fd: Int): Boolean {
|
|
||||||
return (flClashService as? FlClashVpnService)?.protect(fd) == true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun resolverProcess(
|
|
||||||
protocol: Int,
|
|
||||||
source: InetSocketAddress,
|
|
||||||
target: InetSocketAddress,
|
|
||||||
uid: Int,
|
|
||||||
): String {
|
|
||||||
val nextUid = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
||||||
connectivity?.getConnectionOwnerUid(protocol, source, target) ?: -1
|
|
||||||
} else {
|
|
||||||
uid
|
|
||||||
}
|
|
||||||
if (nextUid == -1) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if (!uidPageNameMap.containsKey(nextUid)) {
|
|
||||||
uidPageNameMap[nextUid] =
|
|
||||||
FlClashApplication.getAppContext().packageManager?.getPackagesForUid(nextUid)
|
|
||||||
?.first() ?: ""
|
|
||||||
}
|
|
||||||
return uidPageNameMap[nextUid] ?: ""
|
|
||||||
}
|
|
||||||
|
|
||||||
fun handleStop() {
|
|
||||||
GlobalState.runLock.withLock {
|
|
||||||
if (GlobalState.runState.value == RunState.STOP) return
|
|
||||||
GlobalState.runState.value = RunState.STOP
|
|
||||||
stopForegroundJob()
|
|
||||||
Core.stopTun()
|
|
||||||
flClashService?.stop()
|
|
||||||
GlobalState.handleTryDestroy()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun bindService() {
|
|
||||||
if (isBind) {
|
|
||||||
FlClashApplication.getAppContext().unbindService(connection)
|
|
||||||
}
|
|
||||||
val intent = when (options?.enable == true) {
|
|
||||||
true -> Intent(FlClashApplication.getAppContext(), FlClashVpnService::class.java)
|
|
||||||
false -> Intent(FlClashApplication.getAppContext(), FlClashService::class.java)
|
|
||||||
}
|
|
||||||
FlClashApplication.getAppContext().bindService(intent, connection, Context.BIND_AUTO_CREATE)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
package com.follow.clash.services
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.app.Notification
|
|
||||||
import android.app.Notification.FOREGROUND_SERVICE_IMMEDIATE
|
|
||||||
import android.app.NotificationChannel
|
|
||||||
import android.app.NotificationManager
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.app.Service
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
|
||||||
import android.os.Build
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import com.follow.clash.GlobalState
|
|
||||||
import com.follow.clash.MainActivity
|
|
||||||
import com.follow.clash.R
|
|
||||||
import com.follow.clash.extensions.getActionPendingIntent
|
|
||||||
import com.follow.clash.models.VpnOptions
|
|
||||||
import io.flutter.Log
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Deferred
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.async
|
|
||||||
|
|
||||||
interface BaseServiceInterface {
|
|
||||||
|
|
||||||
fun start(options: VpnOptions): Int
|
|
||||||
|
|
||||||
fun stop()
|
|
||||||
|
|
||||||
suspend fun startForeground(title: String, content: String)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun Service.createFlClashNotificationBuilder(): Deferred<NotificationCompat.Builder> =
|
|
||||||
CoroutineScope(Dispatchers.Main).async {
|
|
||||||
val stopText = GlobalState.getText("stop")
|
|
||||||
val intent = Intent(this@createFlClashNotificationBuilder, MainActivity::class.java)
|
|
||||||
|
|
||||||
val pendingIntent = if (Build.VERSION.SDK_INT >= 31) {
|
|
||||||
PendingIntent.getActivity(
|
|
||||||
this@createFlClashNotificationBuilder,
|
|
||||||
0,
|
|
||||||
intent,
|
|
||||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
PendingIntent.getActivity(
|
|
||||||
this@createFlClashNotificationBuilder, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
with(
|
|
||||||
NotificationCompat.Builder(
|
|
||||||
this@createFlClashNotificationBuilder, GlobalState.NOTIFICATION_CHANNEL
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
setSmallIcon(R.drawable.ic_stat_name)
|
|
||||||
setContentTitle("FlClash")
|
|
||||||
setContentIntent(pendingIntent)
|
|
||||||
setCategory(NotificationCompat.CATEGORY_SERVICE)
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
||||||
foregroundServiceBehavior = FOREGROUND_SERVICE_IMMEDIATE
|
|
||||||
}
|
|
||||||
setOngoing(true)
|
|
||||||
addAction(
|
|
||||||
0, stopText, getActionPendingIntent("STOP")
|
|
||||||
)
|
|
||||||
setShowWhen(false)
|
|
||||||
setOnlyAlertOnce(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("ForegroundServiceType")
|
|
||||||
fun Service.startForeground(notification: Notification) {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
val manager = getSystemService(NotificationManager::class.java)
|
|
||||||
var channel = manager?.getNotificationChannel(GlobalState.NOTIFICATION_CHANNEL)
|
|
||||||
if (channel == null) {
|
|
||||||
Log.d("[FlClash]","createNotificationChannel===>")
|
|
||||||
channel = NotificationChannel(
|
|
||||||
GlobalState.NOTIFICATION_CHANNEL, "FlClash", NotificationManager.IMPORTANCE_LOW
|
|
||||||
)
|
|
||||||
manager?.createNotificationChannel(channel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
|
||||||
try {
|
|
||||||
startForeground(
|
|
||||||
GlobalState.NOTIFICATION_ID, notification, FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
|
||||||
)
|
|
||||||
} catch (_: Exception) {
|
|
||||||
startForeground(GlobalState.NOTIFICATION_ID, notification)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
startForeground(GlobalState.NOTIFICATION_ID, notification)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
package com.follow.clash.services
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.app.Service
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Binder
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.IBinder
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import com.follow.clash.GlobalState
|
|
||||||
import com.follow.clash.models.VpnOptions
|
|
||||||
|
|
||||||
|
|
||||||
class FlClashService : Service(), BaseServiceInterface {
|
|
||||||
|
|
||||||
override fun start(options: VpnOptions) = 0
|
|
||||||
|
|
||||||
override fun stop() {
|
|
||||||
stopSelf()
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
||||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var cachedBuilder: NotificationCompat.Builder? = null
|
|
||||||
|
|
||||||
private suspend fun notificationBuilder(): NotificationCompat.Builder {
|
|
||||||
if (cachedBuilder == null) {
|
|
||||||
cachedBuilder = createFlClashNotificationBuilder().await()
|
|
||||||
}
|
|
||||||
return cachedBuilder!!
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("ForegroundServiceType")
|
|
||||||
override suspend fun startForeground(title: String, content: String) {
|
|
||||||
startForeground(
|
|
||||||
notificationBuilder()
|
|
||||||
.setContentTitle(title)
|
|
||||||
.setContentText(content).build()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onTrimMemory(level: Int) {
|
|
||||||
super.onTrimMemory(level)
|
|
||||||
GlobalState.getCurrentVPNPlugin()?.requestGc()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private val binder = LocalBinder()
|
|
||||||
|
|
||||||
inner class LocalBinder : Binder() {
|
|
||||||
fun getService(): FlClashService = this@FlClashService
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onBind(intent: Intent): IBinder {
|
|
||||||
return binder
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onUnbind(intent: Intent?): Boolean {
|
|
||||||
return super.onUnbind(intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
stop()
|
|
||||||
super.onDestroy()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
package com.follow.clash.services
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Build
|
|
||||||
import android.service.quicksettings.Tile
|
|
||||||
import android.service.quicksettings.TileService
|
|
||||||
import androidx.annotation.RequiresApi
|
|
||||||
import androidx.lifecycle.Observer
|
|
||||||
import com.follow.clash.GlobalState
|
|
||||||
import com.follow.clash.RunState
|
|
||||||
import com.follow.clash.TempActivity
|
|
||||||
|
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.N)
|
|
||||||
class FlClashTileService : TileService() {
|
|
||||||
|
|
||||||
private val observer = Observer<RunState> { runState ->
|
|
||||||
updateTile(runState)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateTile(runState: RunState) {
|
|
||||||
if (qsTile != null) {
|
|
||||||
qsTile.state = when (runState) {
|
|
||||||
RunState.START -> Tile.STATE_ACTIVE
|
|
||||||
RunState.PENDING -> Tile.STATE_UNAVAILABLE
|
|
||||||
RunState.STOP -> Tile.STATE_INACTIVE
|
|
||||||
}
|
|
||||||
qsTile.updateTile()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStartListening() {
|
|
||||||
super.onStartListening()
|
|
||||||
GlobalState.runState.value?.let { updateTile(it) }
|
|
||||||
GlobalState.runState.observeForever(observer)
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("StartActivityAndCollapseDeprecated")
|
|
||||||
private fun activityTransfer() {
|
|
||||||
val intent = Intent(this, TempActivity::class.java)
|
|
||||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK)
|
|
||||||
val pendingIntent = if (Build.VERSION.SDK_INT >= 31) {
|
|
||||||
PendingIntent.getActivity(
|
|
||||||
this,
|
|
||||||
0,
|
|
||||||
intent,
|
|
||||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
PendingIntent.getActivity(
|
|
||||||
this,
|
|
||||||
0,
|
|
||||||
intent,
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
|
||||||
startActivityAndCollapse(pendingIntent)
|
|
||||||
} else {
|
|
||||||
startActivityAndCollapse(intent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onClick() {
|
|
||||||
super.onClick()
|
|
||||||
activityTransfer()
|
|
||||||
GlobalState.handleToggle()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
GlobalState.runState.removeObserver(observer)
|
|
||||||
super.onDestroy()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
package com.follow.clash.services
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.content.Intent
|
|
||||||
import android.net.ProxyInfo
|
|
||||||
import android.net.VpnService
|
|
||||||
import android.os.Binder
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.IBinder
|
|
||||||
import android.os.Parcel
|
|
||||||
import android.os.RemoteException
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import com.follow.clash.GlobalState
|
|
||||||
import com.follow.clash.extensions.getIpv4RouteAddress
|
|
||||||
import com.follow.clash.extensions.getIpv6RouteAddress
|
|
||||||
import com.follow.clash.extensions.toCIDR
|
|
||||||
import com.follow.clash.models.AccessControlMode
|
|
||||||
import com.follow.clash.models.VpnOptions
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
|
|
||||||
class FlClashVpnService : VpnService(), BaseServiceInterface {
|
|
||||||
override fun onCreate() {
|
|
||||||
super.onCreate()
|
|
||||||
GlobalState.initServiceEngine()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun start(options: VpnOptions): Int {
|
|
||||||
return with(Builder()) {
|
|
||||||
if (options.ipv4Address.isNotEmpty()) {
|
|
||||||
val cidr = options.ipv4Address.toCIDR()
|
|
||||||
addAddress(cidr.address, cidr.prefixLength)
|
|
||||||
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("0.0.0.0", 0)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
addRoute("0.0.0.0", 0)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
addRoute("0.0.0.0", 0)
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
if (options.ipv6Address.isNotEmpty()) {
|
|
||||||
val cidr = options.ipv6Address.toCIDR()
|
|
||||||
Log.d(
|
|
||||||
"addAddress6",
|
|
||||||
"address: ${cidr.address} prefixLength:${cidr.prefixLength}"
|
|
||||||
)
|
|
||||||
addAddress(cidr.address, cidr.prefixLength)
|
|
||||||
val routeAddress = options.getIpv6RouteAddress()
|
|
||||||
if (routeAddress.isNotEmpty()) {
|
|
||||||
try {
|
|
||||||
routeAddress.forEach { i ->
|
|
||||||
Log.d(
|
|
||||||
"addRoute6",
|
|
||||||
"address: ${i.address} prefixLength:${i.prefixLength}"
|
|
||||||
)
|
|
||||||
addRoute(i.address, i.prefixLength)
|
|
||||||
}
|
|
||||||
} catch (_: Exception) {
|
|
||||||
addRoute("::", 0)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
addRoute("::", 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}catch (_:Exception){
|
|
||||||
Log.d(
|
|
||||||
"addAddress6",
|
|
||||||
"IPv6 is not supported."
|
|
||||||
)
|
|
||||||
}
|
|
||||||
addDnsServer(options.dnsServerAddress)
|
|
||||||
setMtu(9000)
|
|
||||||
options.accessControl.let { accessControl ->
|
|
||||||
if (accessControl.enable) {
|
|
||||||
when (accessControl.mode) {
|
|
||||||
AccessControlMode.acceptSelected -> {
|
|
||||||
(accessControl.acceptList + packageName).forEach {
|
|
||||||
addAllowedApplication(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
AccessControlMode.rejectSelected -> {
|
|
||||||
(accessControl.rejectList - packageName).forEach {
|
|
||||||
addDisallowedApplication(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setSession("FlClash")
|
|
||||||
setBlocking(false)
|
|
||||||
if (Build.VERSION.SDK_INT >= 29) {
|
|
||||||
setMetered(false)
|
|
||||||
}
|
|
||||||
if (options.allowBypass) {
|
|
||||||
allowBypass()
|
|
||||||
}
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && options.systemProxy) {
|
|
||||||
setHttpProxy(
|
|
||||||
ProxyInfo.buildDirectProxy(
|
|
||||||
"127.0.0.1",
|
|
||||||
options.port,
|
|
||||||
options.bypassDomain
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
establish()?.detachFd()
|
|
||||||
?: throw NullPointerException("Establish VPN rejected by system")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun stop() {
|
|
||||||
stopSelf()
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
||||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var cachedBuilder: NotificationCompat.Builder? = null
|
|
||||||
|
|
||||||
private suspend fun notificationBuilder(): NotificationCompat.Builder {
|
|
||||||
if (cachedBuilder == null) {
|
|
||||||
cachedBuilder = createFlClashNotificationBuilder().await()
|
|
||||||
}
|
|
||||||
return cachedBuilder!!
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("ForegroundServiceType")
|
|
||||||
override suspend fun startForeground(title: String, content: String) {
|
|
||||||
startForeground(
|
|
||||||
notificationBuilder()
|
|
||||||
.setContentTitle(title)
|
|
||||||
.setContentText(content).build()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onTrimMemory(level: Int) {
|
|
||||||
super.onTrimMemory(level)
|
|
||||||
GlobalState.getCurrentVPNPlugin()?.requestGc()
|
|
||||||
}
|
|
||||||
|
|
||||||
private val binder = LocalBinder()
|
|
||||||
|
|
||||||
inner class LocalBinder : Binder() {
|
|
||||||
fun getService(): FlClashVpnService = this@FlClashVpnService
|
|
||||||
|
|
||||||
override fun onTransact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean {
|
|
||||||
try {
|
|
||||||
val isSuccess = super.onTransact(code, data, reply, flags)
|
|
||||||
if (!isSuccess) {
|
|
||||||
CoroutineScope(Dispatchers.Main).launch {
|
|
||||||
GlobalState.getCurrentTilePlugin()?.handleStop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return isSuccess
|
|
||||||
} catch (e: RemoteException) {
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onBind(intent: Intent): IBinder {
|
|
||||||
return binder
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onUnbind(intent: Intent?): Boolean {
|
|
||||||
return super.onUnbind(intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
stop()
|
|
||||||
super.onDestroy()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 618 B |
Binary file not shown.
|
Before Width: | Height: | Size: 423 B |
Binary file not shown.
|
Before Width: | Height: | Size: 803 B |
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.6 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_banner.png
Normal file
BIN
android/app/src/main/res/mipmap-xhdpi/ic_banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -1,33 +0,0 @@
|
|||||||
buildscript {
|
|
||||||
ext.kotlin_version = "${kotlin_version}"
|
|
||||||
repositories {
|
|
||||||
google()
|
|
||||||
mavenCentral()
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
classpath "com.android.tools.build:gradle:$agp_version"
|
|
||||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
allprojects {
|
|
||||||
repositories {
|
|
||||||
google()
|
|
||||||
mavenCentral()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rootProject.buildDir = '../build'
|
|
||||||
subprojects {
|
|
||||||
project.buildDir = "${rootProject.buildDir}/${project.name}"
|
|
||||||
}
|
|
||||||
subprojects {
|
|
||||||
project.evaluationDependsOn(':app')
|
|
||||||
project.evaluationDependsOn(':core')
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.register("clean", Delete) {
|
|
||||||
delete rootProject.buildDir
|
|
||||||
}
|
|
||||||
|
|
||||||
34
android/build.gradle.kts
Normal file
34
android/build.gradle.kts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
buildscript {
|
||||||
|
dependencies {
|
||||||
|
classpath(libs.build.kotlin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("com.android.library") apply false
|
||||||
|
}
|
||||||
|
|
||||||
|
allprojects {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val newBuildDir: Directory =
|
||||||
|
rootProject.layout.buildDirectory
|
||||||
|
.dir("../../build")
|
||||||
|
.get()
|
||||||
|
rootProject.layout.buildDirectory.value(newBuildDir)
|
||||||
|
|
||||||
|
subprojects {
|
||||||
|
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||||
|
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||||
|
}
|
||||||
|
subprojects {
|
||||||
|
project.evaluationDependsOn(":app")
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.register<Delete>("clean") {
|
||||||
|
delete(rootProject.layout.buildDirectory)
|
||||||
|
}
|
||||||
|
|
||||||
1
android/common/.gitignore
vendored
Normal file
1
android/common/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/build
|
||||||
45
android/common/build.gradle.kts
Normal file
45
android/common/build.gradle.kts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("com.android.library")
|
||||||
|
id("org.jetbrains.kotlin.android")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.follow.clash.common"
|
||||||
|
compileSdk = libs.versions.compileSdk.get().toInt()
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
minSdk = 21
|
||||||
|
consumerProguardFiles("consumer-rules.pro")
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
compilerOptions {
|
||||||
|
jvmTarget.set(JvmTarget.JVM_17)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(libs.androidx.core)
|
||||||
|
implementation(libs.gson)
|
||||||
|
implementation(platform(libs.firebase.bom))
|
||||||
|
implementation(libs.firebase.crashlytics.ndk)
|
||||||
|
implementation(libs.firebase.analytics)
|
||||||
|
}
|
||||||
9
android/common/src/main/AndroidManifest.xml
Normal file
9
android/common/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<permission
|
||||||
|
android:name="${applicationId}.permission.RECEIVE_BROADCASTS"
|
||||||
|
android:protectionLevel="signature" />
|
||||||
|
|
||||||
|
<uses-permission android:name="${applicationId}.permission.RECEIVE_BROADCASTS" />
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.follow.clash.common
|
||||||
|
|
||||||
|
import android.content.ComponentName
|
||||||
|
|
||||||
|
object Components {
|
||||||
|
const val PACKAGE_NAME = "com.follow.clash"
|
||||||
|
|
||||||
|
val MAIN_ACTIVITY =
|
||||||
|
ComponentName(GlobalState.packageName, "${PACKAGE_NAME}.MainActivity")
|
||||||
|
|
||||||
|
val TEMP_ACTIVITY =
|
||||||
|
ComponentName(GlobalState.packageName, "${PACKAGE_NAME}.TempActivity")
|
||||||
|
|
||||||
|
val BROADCAST_RECEIVER =
|
||||||
|
ComponentName(GlobalState.packageName, "${PACKAGE_NAME}.BroadcastReceiver")
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.follow.clash.common
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
|
|
||||||
|
|
||||||
|
enum class QuickAction {
|
||||||
|
STOP,
|
||||||
|
START,
|
||||||
|
TOGGLE,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class BroadcastAction {
|
||||||
|
SERVICE_CREATED,
|
||||||
|
SERVICE_DESTROYED,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class AccessControlMode {
|
||||||
|
@SerializedName("acceptSelected")
|
||||||
|
ACCEPT_SELECTED,
|
||||||
|
|
||||||
|
@SerializedName("rejectSelected")
|
||||||
|
REJECT_SELECTED,
|
||||||
|
}
|
||||||
250
android/common/src/main/java/com/follow/clash/common/Ext.kt
Normal file
250
android/common/src/main/java/com/follow/clash/common/Ext.kt
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
package com.follow.clash.common
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.ActivityManager
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Context.RECEIVER_NOT_EXPORTED
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.content.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)
|
||||||
|
}
|
||||||
|
|
||||||
|
val QuickAction.action: String
|
||||||
|
get() = "${GlobalState.application.packageName}.action.${this.name}"
|
||||||
|
|
||||||
|
val QuickAction.quickIntent: Intent
|
||||||
|
get() = Components.TEMP_ACTIVITY.intent.apply {
|
||||||
|
action = this@quickIntent.action
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_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
|
||||||
|
}
|
||||||
|
|
||||||
|
fun BroadcastAction.sendBroadcast() {
|
||||||
|
val intent = Intent().apply {
|
||||||
|
action = this@sendBroadcast.action
|
||||||
|
Log.d("[sendBroadcast]", "$action")
|
||||||
|
setPackage(GlobalState.packageName)
|
||||||
|
}
|
||||||
|
GlobalState.application.sendBroadcast(
|
||||||
|
intent, GlobalState.RECEIVE_BROADCASTS_PERMISSIONS
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
val Intent.toPendingIntent: PendingIntent
|
||||||
|
get() = PendingIntent.getActivity(
|
||||||
|
GlobalState.application,
|
||||||
|
0,
|
||||||
|
this,
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
fun Service.startForeground(notification: Notification) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val manager = getSystemService(NotificationManager::class.java)
|
||||||
|
var channel = manager?.getNotificationChannel(GlobalState.NOTIFICATION_CHANNEL)
|
||||||
|
if (channel == null) {
|
||||||
|
channel = NotificationChannel(
|
||||||
|
GlobalState.NOTIFICATION_CHANNEL,
|
||||||
|
"SERVICE_CHANNEL",
|
||||||
|
NotificationManager.IMPORTANCE_LOW
|
||||||
|
)
|
||||||
|
manager?.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
startForegroundCompat(GlobalState.NOTIFICATION_ID, notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("UnspecifiedRegisterReceiverFlag")
|
||||||
|
fun Context.registerReceiverCompat(
|
||||||
|
receiver: BroadcastReceiver,
|
||||||
|
filter: IntentFilter,
|
||||||
|
) = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
registerReceiver(receiver, filter, RECEIVER_NOT_EXPORTED)
|
||||||
|
} else {
|
||||||
|
registerReceiver(receiver, filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Context.receiveBroadcastFlow(
|
||||||
|
configure: IntentFilter.() -> Unit,
|
||||||
|
): Flow<Intent> = callbackFlow {
|
||||||
|
val filter = IntentFilter().apply(configure)
|
||||||
|
val receiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
|
if (context == null || intent == null) return
|
||||||
|
trySend(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
registerReceiverCompat(receiver, filter)
|
||||||
|
awaitClose { unregisterReceiver(receiver) }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.follow.clash.common
|
||||||
|
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.util.Log
|
||||||
|
import com.google.firebase.FirebaseApp
|
||||||
|
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
|
||||||
|
object GlobalState : CoroutineScope by CoroutineScope(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 application: Application
|
||||||
|
get() = _application!!
|
||||||
|
|
||||||
|
|
||||||
|
fun log(text: String) {
|
||||||
|
Log.d("[FlClash]", text)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun init(application: Application) {
|
||||||
|
_application = application
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCrashlytics(enable: Boolean) {
|
||||||
|
_application?.let {
|
||||||
|
FirebaseApp.initializeApp(it)
|
||||||
|
FirebaseCrashlytics.getInstance().isCrashlyticsCollectionEnabled = enable
|
||||||
|
if (enable) {
|
||||||
|
log("init crashlytics ${it.processName}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
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,4 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="fl_clash">FlClash</string>
|
<string name="FlClash">FlClash</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.library")
|
id("com.android.library")
|
||||||
id("org.jetbrains.kotlin.android")
|
id("org.jetbrains.kotlin.android")
|
||||||
@@ -5,22 +7,13 @@ plugins {
|
|||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.follow.clash.core"
|
namespace = "com.follow.clash.core"
|
||||||
compileSdk = 35
|
compileSdk = libs.versions.compileSdk.get().toInt()
|
||||||
ndkVersion = "28.0.13004108"
|
ndkVersion = libs.versions.ndkVersion.get()
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
minSdk = 21
|
minSdk = libs.versions.minSdk.get().toInt()
|
||||||
consumerProguardFiles("consumer-rules.pro")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
|
||||||
release {
|
|
||||||
proguardFiles(
|
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
|
||||||
"proguard-rules.pro"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
getByName("main") {
|
getByName("main") {
|
||||||
@@ -35,17 +28,30 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = "17"
|
|
||||||
}
|
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
compilerOptions {
|
||||||
|
jvmTarget.set(JvmTarget.JVM_17)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation("androidx.annotation:annotation-jvm:1.9.1")
|
implementation(libs.annotation.jvm)
|
||||||
}
|
}
|
||||||
|
|
||||||
val copyNativeLibs by tasks.register<Copy>("copyNativeLibs") {
|
val copyNativeLibs by tasks.register<Copy>("copyNativeLibs") {
|
||||||
@@ -54,6 +60,18 @@ val copyNativeLibs by tasks.register<Copy>("copyNativeLibs") {
|
|||||||
}
|
}
|
||||||
from("../../libclash/android")
|
from("../../libclash/android")
|
||||||
into("src/main/jniLibs")
|
into("src/main/jniLibs")
|
||||||
|
|
||||||
|
doLast {
|
||||||
|
val includesDir = file("src/main/jniLibs/includes")
|
||||||
|
val targetDir = file("src/main/cpp/includes")
|
||||||
|
if (includesDir.exists()) {
|
||||||
|
copy {
|
||||||
|
from(includesDir)
|
||||||
|
into(targetDir)
|
||||||
|
}
|
||||||
|
delete(includesDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
afterEvaluate {
|
afterEvaluate {
|
||||||
|
|||||||
21
android/core/proguard-rules.pro
vendored
21
android/core/proguard-rules.pro
vendored
@@ -1,21 +0,0 @@
|
|||||||
# Add project specific ProGuard rules here.
|
|
||||||
# You can control the set of applied configuration files using the
|
|
||||||
# proguardFiles setting in build.gradle.
|
|
||||||
#
|
|
||||||
# For more details, see
|
|
||||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
|
||||||
|
|
||||||
# If your project uses WebView with JS, uncomment the following
|
|
||||||
# and specify the fully qualified class name to the JavaScript interface
|
|
||||||
# class:
|
|
||||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
|
||||||
# public *;
|
|
||||||
#}
|
|
||||||
|
|
||||||
# Uncomment this to preserve the line number information for
|
|
||||||
# debugging stack traces.
|
|
||||||
#-keepattributes SourceFile,LineNumberTable
|
|
||||||
|
|
||||||
# If you keep the line number information, uncomment this to
|
|
||||||
# hide the original source file name.
|
|
||||||
#-renamesourcefileattribute SourceFile
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -4,7 +4,11 @@ project("core")
|
|||||||
|
|
||||||
message("CMAKE_SOURCE_DIR ${CMAKE_SOURCE_DIR}")
|
message("CMAKE_SOURCE_DIR ${CMAKE_SOURCE_DIR}")
|
||||||
|
|
||||||
|
message("CMAKE_BUILD_TYPE ${CMAKE_BUILD_TYPE}")
|
||||||
|
|
||||||
|
|
||||||
if (NOT "${CMAKE_BUILD_TYPE}" STREQUAL "Debug")
|
if (NOT "${CMAKE_BUILD_TYPE}" STREQUAL "Debug")
|
||||||
|
# set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
|
||||||
add_compile_options(-O3)
|
add_compile_options(-O3)
|
||||||
|
|
||||||
add_compile_options(-flto)
|
add_compile_options(-flto)
|
||||||
@@ -31,7 +35,7 @@ message("LIB_CLASH_PATH ${LIB_CLASH_PATH}")
|
|||||||
if (EXISTS ${LIB_CLASH_PATH})
|
if (EXISTS ${LIB_CLASH_PATH})
|
||||||
message("Found libclash.so for ABI ${ANDROID_ABI}")
|
message("Found libclash.so for ABI ${ANDROID_ABI}")
|
||||||
add_compile_definitions(LIBCLASH)
|
add_compile_definitions(LIBCLASH)
|
||||||
include_directories(${CMAKE_SOURCE_DIR}/../jniLibs/${ANDROID_ABI})
|
include_directories(${CMAKE_SOURCE_DIR}/../cpp/includes/${ANDROID_ABI})
|
||||||
link_directories(${CMAKE_SOURCE_DIR}/../jniLibs/${ANDROID_ABI})
|
link_directories(${CMAKE_SOURCE_DIR}/../jniLibs/${ANDROID_ABI})
|
||||||
add_library(${CMAKE_PROJECT_NAME} SHARED
|
add_library(${CMAKE_PROJECT_NAME} SHARED
|
||||||
jni_helper.cpp
|
jni_helper.cpp
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
#include <jni.h>
|
#include <jni.h>
|
||||||
|
|
||||||
#ifdef LIBCLASH
|
#ifdef LIBCLASH
|
||||||
#include <jni.h>
|
|
||||||
#include <string>
|
|
||||||
#include "jni_helper.h"
|
#include "jni_helper.h"
|
||||||
#include "libclash.h"
|
#include "libclash.h"
|
||||||
|
#include "bride.h"
|
||||||
|
|
||||||
extern "C"
|
extern "C"
|
||||||
JNIEXPORT void JNICALL
|
JNIEXPORT void JNICALL
|
||||||
Java_com_follow_clash_core_Core_startTun(JNIEnv *env, jobject thiz, jint fd, jobject cb) {
|
Java_com_follow_clash_core_Core_startTun(JNIEnv *env, jobject thiz, jint fd, jobject cb,
|
||||||
auto interface = new_global(cb);
|
jstring stack, jstring address, jstring dns) {
|
||||||
startTUN(fd, interface);
|
const auto interface = new_global(cb);
|
||||||
|
startTUN(interface, fd, get_string(stack), get_string(address), get_string(dns));
|
||||||
}
|
}
|
||||||
|
|
||||||
extern "C"
|
extern "C"
|
||||||
@@ -19,57 +20,175 @@ Java_com_follow_clash_core_Core_stopTun(JNIEnv *env, jobject thiz) {
|
|||||||
stopTun();
|
stopTun();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extern "C"
|
||||||
|
JNIEXPORT void JNICALL
|
||||||
|
Java_com_follow_clash_core_Core_forceGC(JNIEnv *env, jobject thiz) {
|
||||||
|
forceGC();
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C"
|
||||||
|
JNIEXPORT void JNICALL
|
||||||
|
Java_com_follow_clash_core_Core_updateDNS(JNIEnv *env, jobject thiz, jstring dns) {
|
||||||
|
updateDns(get_string(dns));
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C"
|
||||||
|
JNIEXPORT void JNICALL
|
||||||
|
Java_com_follow_clash_core_Core_invokeAction(JNIEnv *env, jobject thiz, jstring data, jobject cb) {
|
||||||
|
const auto interface = new_global(cb);
|
||||||
|
invokeAction(interface, get_string(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C"
|
||||||
|
JNIEXPORT void JNICALL
|
||||||
|
Java_com_follow_clash_core_Core_setEventListener(JNIEnv *env, jobject thiz, jobject cb) {
|
||||||
|
if (cb != nullptr) {
|
||||||
|
const auto interface = new_global(cb);
|
||||||
|
setEventListener(interface);
|
||||||
|
} else {
|
||||||
|
setEventListener(nullptr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C"
|
||||||
|
JNIEXPORT jstring JNICALL
|
||||||
|
Java_com_follow_clash_core_Core_getTraffic(JNIEnv *env, jobject thiz,
|
||||||
|
const jboolean only_statistics_proxy) {
|
||||||
|
return new_string(getTraffic(only_statistics_proxy));
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C"
|
||||||
|
JNIEXPORT jstring JNICALL
|
||||||
|
Java_com_follow_clash_core_Core_getTotalTraffic(JNIEnv *env, jobject thiz,
|
||||||
|
const jboolean only_statistics_proxy) {
|
||||||
|
return new_string(getTotalTraffic(only_statistics_proxy));
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C"
|
||||||
|
JNIEXPORT void JNICALL
|
||||||
|
Java_com_follow_clash_core_Core_suspended(JNIEnv *env, jobject thiz, jboolean suspended) {
|
||||||
|
suspend(suspended);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
static jmethodID m_tun_interface_protect;
|
static jmethodID m_tun_interface_protect;
|
||||||
static jmethodID m_tun_interface_resolve_process;
|
static jmethodID m_tun_interface_resolve_process;
|
||||||
|
static jmethodID m_invoke_interface_result;
|
||||||
|
|
||||||
|
|
||||||
static void release_jni_object_impl(void *obj) {
|
static void release_jni_object_impl(void *obj) {
|
||||||
ATTACH_JNI();
|
ATTACH_JNI();
|
||||||
del_global((jobject) obj);
|
del_global(static_cast<jobject>(obj));
|
||||||
}
|
}
|
||||||
|
|
||||||
static void call_tun_interface_protect_impl(void *tun_interface, int fd) {
|
static void free_string_impl(char *str) {
|
||||||
ATTACH_JNI();
|
free(str);
|
||||||
env->CallVoidMethod((jobject) tun_interface,
|
|
||||||
(jmethodID) m_tun_interface_protect,
|
|
||||||
(jint) fd);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static const char*
|
static void call_tun_interface_protect_impl(void *tun_interface, const int fd) {
|
||||||
call_tun_interface_resolve_process_impl(void *tun_interface, int protocol,
|
|
||||||
const char *source,
|
|
||||||
const char *target,
|
|
||||||
int uid) {
|
|
||||||
ATTACH_JNI();
|
ATTACH_JNI();
|
||||||
jstring packageName = (jstring)env->CallObjectMethod((jobject) tun_interface,
|
env->CallVoidMethod(static_cast<jobject>(tun_interface),
|
||||||
(jmethodID) m_tun_interface_resolve_process,
|
m_tun_interface_protect,
|
||||||
(jint) protocol,
|
fd);
|
||||||
(jstring) new_string(source),
|
}
|
||||||
(jstring) new_string(target),
|
|
||||||
(jint) uid);
|
static char *
|
||||||
|
call_tun_interface_resolve_process_impl(void *tun_interface, const int protocol,
|
||||||
|
const char *source,
|
||||||
|
const char *target,
|
||||||
|
const int uid) {
|
||||||
|
ATTACH_JNI();
|
||||||
|
const auto packageName = reinterpret_cast<jstring>(env->CallObjectMethod(
|
||||||
|
static_cast<jobject>(tun_interface),
|
||||||
|
m_tun_interface_resolve_process,
|
||||||
|
protocol,
|
||||||
|
new_string(source),
|
||||||
|
new_string(target),
|
||||||
|
uid));
|
||||||
return get_string(packageName);
|
return get_string(packageName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void call_invoke_interface_result_impl(void *invoke_interface, const char *data) {
|
||||||
|
ATTACH_JNI();
|
||||||
|
env->CallVoidMethod(static_cast<jobject>(invoke_interface),
|
||||||
|
m_invoke_interface_result,
|
||||||
|
new_string(data));
|
||||||
|
}
|
||||||
|
|
||||||
extern "C"
|
extern "C"
|
||||||
JNIEXPORT jint JNICALL
|
JNIEXPORT jint JNICALL
|
||||||
JNI_OnLoad(JavaVM *vm, void *reserved) {
|
JNI_OnLoad(JavaVM *vm, void *) {
|
||||||
JNIEnv *env = nullptr;
|
JNIEnv *env = nullptr;
|
||||||
if (vm->GetEnv((void **) &env, JNI_VERSION_1_6) != JNI_OK) {
|
if (vm->GetEnv(reinterpret_cast<void **>(&env), JNI_VERSION_1_6) != JNI_OK) {
|
||||||
return JNI_ERR;
|
return JNI_ERR;
|
||||||
}
|
}
|
||||||
|
|
||||||
initialize_jni(vm, env);
|
initialize_jni(vm, env);
|
||||||
|
|
||||||
jclass c_tun_interface = find_class("com/follow/clash/core/TunInterface");
|
const auto c_tun_interface = find_class("com/follow/clash/core/TunInterface");
|
||||||
|
|
||||||
|
const auto c_invoke_interface = find_class("com/follow/clash/core/InvokeInterface");
|
||||||
|
|
||||||
m_tun_interface_protect = find_method(c_tun_interface, "protect", "(I)V");
|
m_tun_interface_protect = find_method(c_tun_interface, "protect", "(I)V");
|
||||||
m_tun_interface_resolve_process = find_method(c_tun_interface, "resolverProcess",
|
m_tun_interface_resolve_process = find_method(c_tun_interface, "resolverProcess",
|
||||||
"(ILjava/lang/String;Ljava/lang/String;I)Ljava/lang/String;");
|
"(ILjava/lang/String;Ljava/lang/String;I)Ljava/lang/String;");
|
||||||
|
m_invoke_interface_result = find_method(c_invoke_interface, "onResult",
|
||||||
|
"(Ljava/lang/String;)V");
|
||||||
|
|
||||||
|
|
||||||
|
protect_func = &call_tun_interface_protect_impl;
|
||||||
|
resolve_process_func = &call_tun_interface_resolve_process_impl;
|
||||||
|
result_func = &call_invoke_interface_result_impl;
|
||||||
|
release_object_func = &release_jni_object_impl;
|
||||||
|
free_string_func = &free_string_impl;
|
||||||
|
|
||||||
registerCallbacks(&call_tun_interface_protect_impl,
|
|
||||||
&call_tun_interface_resolve_process_impl,
|
|
||||||
&release_jni_object_impl);
|
|
||||||
return JNI_VERSION_1_6;
|
return JNI_VERSION_1_6;
|
||||||
}
|
}
|
||||||
#endif
|
#else
|
||||||
|
extern "C"
|
||||||
|
JNIEXPORT void JNICALL
|
||||||
|
Java_com_follow_clash_core_Core_startTun(JNIEnv *env, jobject thiz, jint fd, jobject cb,
|
||||||
|
jstring stack, jstring address, jstring dns) {
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C"
|
||||||
|
JNIEXPORT void JNICALL
|
||||||
|
Java_com_follow_clash_core_Core_stopTun(JNIEnv *env, jobject thiz) {
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C"
|
||||||
|
JNIEXPORT void JNICALL
|
||||||
|
Java_com_follow_clash_core_Core_invokeAction(JNIEnv *env, jobject thiz, jstring data, jobject cb) {
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C"
|
||||||
|
JNIEXPORT void JNICALL
|
||||||
|
Java_com_follow_clash_core_Core_forceGC(JNIEnv *env, jobject thiz) {
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C"
|
||||||
|
JNIEXPORT void JNICALL
|
||||||
|
Java_com_follow_clash_core_Core_updateDNS(JNIEnv *env, jobject thiz, jstring dns) {
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C"
|
||||||
|
JNIEXPORT void JNICALL
|
||||||
|
Java_com_follow_clash_core_Core_setEventListener(JNIEnv *env, jobject thiz, jobject cb) {
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C"
|
||||||
|
JNIEXPORT jstring JNICALL
|
||||||
|
Java_com_follow_clash_core_Core_getTraffic(JNIEnv *env, jobject thiz,
|
||||||
|
const jboolean only_statistics_proxy) {
|
||||||
|
}
|
||||||
|
extern "C"
|
||||||
|
JNIEXPORT jstring JNICALL
|
||||||
|
Java_com_follow_clash_core_Core_getTotalTraffic(JNIEnv *env, jobject thiz,
|
||||||
|
const jboolean only_statistics_proxy) {
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C"
|
||||||
|
JNIEXPORT void JNICALL
|
||||||
|
Java_com_follow_clash_core_Core_suspended(JNIEnv *env, jobject thiz, jboolean suspended) {
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#include "jni_helper.h"
|
#include "jni_helper.h"
|
||||||
|
|
||||||
|
#include <cstdlib>
|
||||||
#include <malloc.h>
|
#include <malloc.h>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
|
||||||
@@ -12,7 +13,7 @@ static jmethodID m_get_bytes;
|
|||||||
void initialize_jni(JavaVM *vm, JNIEnv *env) {
|
void initialize_jni(JavaVM *vm, JNIEnv *env) {
|
||||||
global_vm = vm;
|
global_vm = vm;
|
||||||
|
|
||||||
c_string = (jclass) new_global(find_class("java/lang/String"));
|
c_string = reinterpret_cast<jclass>(new_global(find_class("java/lang/String")));
|
||||||
m_new_string = find_method(c_string, "<init>", "([B)V");
|
m_new_string = find_method(c_string, "<init>", "([B)V");
|
||||||
m_get_bytes = find_method(c_string, "getBytes", "()[B");
|
m_get_bytes = find_method(c_string, "getBytes", "()[B");
|
||||||
}
|
}
|
||||||
@@ -22,23 +23,23 @@ JavaVM *global_java_vm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
char *jni_get_string(JNIEnv *env, jstring str) {
|
char *jni_get_string(JNIEnv *env, jstring str) {
|
||||||
auto array = (jbyteArray) env->CallObjectMethod(str, m_get_bytes);
|
const auto array = reinterpret_cast<jbyteArray>(env->CallObjectMethod(str, m_get_bytes));
|
||||||
int length = env->GetArrayLength(array);
|
const int length = env->GetArrayLength(array);
|
||||||
char *content = (char *) malloc(length + 1);
|
const auto content = static_cast<char *>(malloc(length + 1));
|
||||||
env->GetByteArrayRegion(array, 0, length, (jbyte *) content);
|
env->GetByteArrayRegion(array, 0, length, reinterpret_cast<jbyte *>(content));
|
||||||
content[length] = 0;
|
content[length] = 0;
|
||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
jstring jni_new_string(JNIEnv *env, const char *str) {
|
jstring jni_new_string(JNIEnv *env, const char *str) {
|
||||||
auto length = (int) strlen(str);
|
const auto length = static_cast<int>(strlen(str));
|
||||||
jbyteArray array = env->NewByteArray(length);
|
const auto array = env->NewByteArray(length);
|
||||||
env->SetByteArrayRegion(array, 0, length, (const jbyte *) str);
|
env->SetByteArrayRegion(array, 0, length, reinterpret_cast<const jbyte *>(str));
|
||||||
return (jstring) env->NewObject(c_string, m_new_string, array);
|
return reinterpret_cast<jstring>(env->NewObject(c_string, m_new_string, array));
|
||||||
}
|
}
|
||||||
|
|
||||||
int jni_catch_exception(JNIEnv *env) {
|
int jni_catch_exception(JNIEnv *env) {
|
||||||
int result = env->ExceptionCheck();
|
const int result = env->ExceptionCheck();
|
||||||
if (result) {
|
if (result) {
|
||||||
env->ExceptionDescribe();
|
env->ExceptionDescribe();
|
||||||
env->ExceptionClear();
|
env->ExceptionClear();
|
||||||
@@ -46,9 +47,9 @@ int jni_catch_exception(JNIEnv *env) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
void jni_attach_thread(struct scoped_jni *jni) {
|
void jni_attach_thread(scoped_jni *jni) {
|
||||||
JavaVM *vm = global_java_vm();
|
JavaVM *vm = global_java_vm();
|
||||||
if (vm->GetEnv((void **) &jni->env, JNI_VERSION_1_6) == JNI_OK) {
|
if (vm->GetEnv(reinterpret_cast<void **>(&jni->env), JNI_VERSION_1_6) == JNI_OK) {
|
||||||
jni->require_release = 0;
|
jni->require_release = 0;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -58,9 +59,9 @@ void jni_attach_thread(struct scoped_jni *jni) {
|
|||||||
jni->require_release = 1;
|
jni->require_release = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
void jni_detach_thread(struct scoped_jni *jni) {
|
void jni_detach_thread(const scoped_jni *env) {
|
||||||
JavaVM *vm = global_java_vm();
|
JavaVM *vm = global_java_vm();
|
||||||
if (jni->require_release) {
|
if (env->require_release) {
|
||||||
vm->DetachCurrentThread();
|
vm->DetachCurrentThread();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <jni.h>
|
#include <jni.h>
|
||||||
#include <cstdint>
|
|
||||||
#include <cstdlib>
|
|
||||||
#include <malloc.h>
|
|
||||||
|
|
||||||
struct scoped_jni {
|
struct scoped_jni {
|
||||||
JNIEnv *env;
|
JNIEnv *env;
|
||||||
@@ -18,14 +15,14 @@ extern char *jni_get_string(JNIEnv *env, jstring str);
|
|||||||
|
|
||||||
extern int jni_catch_exception(JNIEnv *env);
|
extern int jni_catch_exception(JNIEnv *env);
|
||||||
|
|
||||||
extern void jni_attach_thread(struct scoped_jni *jni);
|
extern void jni_attach_thread(scoped_jni *jni);
|
||||||
|
|
||||||
extern void jni_detach_thread(struct scoped_jni *env);
|
extern void jni_detach_thread(const scoped_jni *env);
|
||||||
|
|
||||||
extern void release_string(char **str);
|
extern void release_string( char **str);
|
||||||
|
|
||||||
#define ATTACH_JNI() __attribute__((unused, cleanup(jni_detach_thread))) \
|
#define ATTACH_JNI() __attribute__((unused, cleanup(jni_detach_thread))) \
|
||||||
struct scoped_jni _jni; \
|
scoped_jni _jni{}; \
|
||||||
jni_attach_thread(&_jni); \
|
jni_attach_thread(&_jni); \
|
||||||
JNIEnv *env = _jni.env
|
JNIEnv *env = _jni.env
|
||||||
|
|
||||||
@@ -36,4 +33,4 @@ extern void release_string(char **str);
|
|||||||
#define new_global(obj) env->NewGlobalRef(obj)
|
#define new_global(obj) env->NewGlobalRef(obj)
|
||||||
#define del_global(obj) env->DeleteGlobalRef(obj)
|
#define del_global(obj) env->DeleteGlobalRef(obj)
|
||||||
#define get_string(jstr) jni_get_string(env, jstr)
|
#define get_string(jstr) jni_get_string(env, jstr)
|
||||||
#define new_string(cstr) jni_new_string(env, cstr)
|
#define new_string(cstr) jni_new_string(env, cstr)
|
||||||
|
|||||||
@@ -7,7 +7,17 @@ import java.net.URL
|
|||||||
data object Core {
|
data object Core {
|
||||||
private external fun startTun(
|
private external fun startTun(
|
||||||
fd: Int,
|
fd: Int,
|
||||||
cb: TunInterface
|
cb: TunInterface,
|
||||||
|
stack: String,
|
||||||
|
address: String,
|
||||||
|
dns: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
external fun forceGC(
|
||||||
|
)
|
||||||
|
|
||||||
|
external fun updateDNS(
|
||||||
|
dns: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun parseInetSocketAddress(address: String): InetSocketAddress {
|
private fun parseInetSocketAddress(address: String): InetSocketAddress {
|
||||||
@@ -19,31 +29,85 @@ data object Core {
|
|||||||
fun startTun(
|
fun startTun(
|
||||||
fd: Int,
|
fd: Int,
|
||||||
protect: (Int) -> Boolean,
|
protect: (Int) -> Boolean,
|
||||||
resolverProcess: (protocol: Int, source: InetSocketAddress, target: InetSocketAddress, uid: Int) -> String
|
resolverProcess: (protocol: Int, source: InetSocketAddress, target: InetSocketAddress, uid: Int) -> String,
|
||||||
|
stack: String,
|
||||||
|
address: String,
|
||||||
|
dns: String,
|
||||||
) {
|
) {
|
||||||
startTun(fd, object : TunInterface {
|
startTun(
|
||||||
override fun protect(fd: Int) {
|
fd,
|
||||||
protect(fd)
|
object : TunInterface {
|
||||||
}
|
override fun protect(fd: Int) {
|
||||||
|
protect(fd)
|
||||||
|
}
|
||||||
|
|
||||||
override fun resolverProcess(
|
override fun resolverProcess(
|
||||||
protocol: Int,
|
protocol: Int,
|
||||||
source: String,
|
source: String,
|
||||||
target: String,
|
target: String,
|
||||||
uid: Int
|
uid: Int
|
||||||
): String {
|
): String {
|
||||||
return resolverProcess(
|
return resolverProcess(
|
||||||
protocol,
|
protocol,
|
||||||
parseInetSocketAddress(source),
|
parseInetSocketAddress(source),
|
||||||
parseInetSocketAddress(target),
|
parseInetSocketAddress(target),
|
||||||
uid,
|
uid,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
stack,
|
||||||
|
address,
|
||||||
|
dns
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
external fun suspended(
|
||||||
|
suspended: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
private external fun invokeAction(
|
||||||
|
data: String,
|
||||||
|
cb: InvokeInterface
|
||||||
|
)
|
||||||
|
|
||||||
|
fun invokeAction(
|
||||||
|
data: String,
|
||||||
|
cb: (result: String?) -> Unit
|
||||||
|
) {
|
||||||
|
invokeAction(
|
||||||
|
data,
|
||||||
|
object : InvokeInterface {
|
||||||
|
override fun onResult(result: String?) {
|
||||||
|
cb(result)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private external fun setEventListener(cb: InvokeInterface?)
|
||||||
|
|
||||||
|
fun callSetEventListener(
|
||||||
|
cb: ((result: String?) -> Unit)?
|
||||||
|
) {
|
||||||
|
when (cb != null) {
|
||||||
|
true -> setEventListener(
|
||||||
|
object : InvokeInterface {
|
||||||
|
override fun onResult(result: String?) {
|
||||||
|
cb(result)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
false -> setEventListener(null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
external fun stopTun()
|
external fun stopTun()
|
||||||
|
|
||||||
|
external fun getTraffic(onlyStatisticsProxy: Boolean): String
|
||||||
|
|
||||||
|
external fun getTotalTraffic(onlyStatisticsProxy: Boolean): String
|
||||||
|
|
||||||
init {
|
init {
|
||||||
System.loadLibrary("core")
|
System.loadLibrary("core")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.follow.clash.core
|
||||||
|
|
||||||
|
import androidx.annotation.Keep
|
||||||
|
|
||||||
|
@Keep
|
||||||
|
interface InvokeInterface {
|
||||||
|
fun onResult(result: String?)
|
||||||
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
org.gradle.jvmargs=-Xmx4G
|
org.gradle.jvmargs=-Xmx4G
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
android.enableJetifier=true
|
android.enableJetifier=true
|
||||||
kotlin_version=1.9.22
|
|
||||||
agp_version=8.9.1
|
|
||||||
|
|||||||
28
android/gradle/libs.versions.toml
Normal file
28
android/gradle/libs.versions.toml
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
[versions]
|
||||||
|
#agp = "8.10.1"
|
||||||
|
firebaseBom = "34.2.0"
|
||||||
|
minSdk = "23"
|
||||||
|
targetSdk = "36"
|
||||||
|
compileSdk = "36"
|
||||||
|
ndkVersion = "28.0.13004108"
|
||||||
|
coreKtx = "1.17.0"
|
||||||
|
annotationJvm = "1.9.1"
|
||||||
|
coreSplashscreen = "1.0.1"
|
||||||
|
gson = "2.13.1"
|
||||||
|
kotlin = "2.2.10"
|
||||||
|
smaliDexlib2 = "3.0.9"
|
||||||
|
firebaseCrashlyticsKtx = "20.0.1"
|
||||||
|
firebaseCommonKtx = "22.0.0"
|
||||||
|
|
||||||
|
[libraries]
|
||||||
|
build-kotlin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
|
||||||
|
androidx-core = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
|
||||||
|
annotation-jvm = { module = "androidx.annotation:annotation-jvm", version.ref = "annotationJvm" }
|
||||||
|
core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" }
|
||||||
|
firebase-analytics = { module = "com.google.firebase:firebase-analytics" }
|
||||||
|
firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBom" }
|
||||||
|
firebase-crashlytics-ndk = { module = "com.google.firebase:firebase-crashlytics-ndk" }
|
||||||
|
gson = { module = "com.google.code.gson:gson", version.ref = "gson" }
|
||||||
|
smali-dexlib2 = { module = "com.android.tools.smali:smali-dexlib2", version.ref = "smaliDexlib2" }
|
||||||
|
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" }
|
||||||
@@ -2,5 +2,5 @@ distributionBase=GRADLE_USER_HOME
|
|||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip
|
||||||
|
|
||||||
|
|||||||
1
android/service/.gitignore
vendored
Normal file
1
android/service/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/build
|
||||||
48
android/service/build.gradle.kts
Normal file
48
android/service/build.gradle.kts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("com.android.library")
|
||||||
|
id("org.jetbrains.kotlin.android")
|
||||||
|
id("kotlin-parcelize")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.follow.clash.service"
|
||||||
|
compileSdk = 36
|
||||||
|
|
||||||
|
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 {
|
||||||
|
compilerOptions {
|
||||||
|
jvmTarget.set(JvmTarget.JVM_17)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":core"))
|
||||||
|
implementation(project(":common"))
|
||||||
|
implementation(libs.gson)
|
||||||
|
implementation(libs.androidx.core)
|
||||||
|
}
|
||||||
49
android/service/src/main/AndroidManifest.xml
Normal file
49
android/service/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
|
||||||
|
<application>
|
||||||
|
<service
|
||||||
|
android:name=".VpnService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="specialUse"
|
||||||
|
android:permission="android.permission.BIND_VPN_SERVICE"
|
||||||
|
android:process=":remote">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.net.VpnService" />
|
||||||
|
</intent-filter>
|
||||||
|
<property
|
||||||
|
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||||
|
android:value="vpn" />
|
||||||
|
</service>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".CommonService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="specialUse"
|
||||||
|
android:process=":remote">
|
||||||
|
<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">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
|
||||||
|
</intent-filter>
|
||||||
|
</provider>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// IAckInterface.aidl
|
||||||
|
package com.follow.clash.service;
|
||||||
|
|
||||||
|
import com.follow.clash.service.IAckInterface;
|
||||||
|
|
||||||
|
interface IAckInterface {
|
||||||
|
oneway void onAck();
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// IRemoteInterface.aidl
|
||||||
|
package com.follow.clash.service;
|
||||||
|
|
||||||
|
import com.follow.clash.service.ICallbackInterface;
|
||||||
|
import com.follow.clash.service.IEventInterface;
|
||||||
|
import com.follow.clash.service.IResultInterface;
|
||||||
|
import com.follow.clash.service.models.VpnOptions;
|
||||||
|
import com.follow.clash.service.models.NotificationParams;
|
||||||
|
|
||||||
|
interface IRemoteInterface {
|
||||||
|
void invokeAction(in String data, in ICallbackInterface callback);
|
||||||
|
void updateNotificationParams(in NotificationParams params);
|
||||||
|
void startService(in VpnOptions options, in long runTime, in IResultInterface result);
|
||||||
|
void stopService(in IResultInterface result);
|
||||||
|
void setEventListener(in IEventInterface event);
|
||||||
|
void setCrashlytics(in boolean enable);
|
||||||
|
long getRunTime();
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// IResultInterface.aidl
|
||||||
|
package com.follow.clash.service;
|
||||||
|
|
||||||
|
interface IResultInterface {
|
||||||
|
oneway void onResult(in long runTime);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
//AccessControl.aidl
|
||||||
|
package com.follow.clash.service.models;
|
||||||
|
|
||||||
|
parcelable AccessControl;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
//NotificationParams.aidl
|
||||||
|
package com.follow.clash.service.models;
|
||||||
|
|
||||||
|
parcelable NotificationParams;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
//VpnOptions.aidl
|
||||||
|
package com.follow.clash.service.models;
|
||||||
|
|
||||||
|
import com.follow.clash.service.models.AccessControl;
|
||||||
|
|
||||||
|
parcelable VpnOptions;
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
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() {
|
||||||
|
loader.load()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun stop() {
|
||||||
|
loader.cancel()
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,35 +1,33 @@
|
|||||||
package com.follow.clash
|
package com.follow.clash.service
|
||||||
|
|
||||||
import android.database.Cursor
|
import android.database.Cursor
|
||||||
import android.database.MatrixCursor
|
import android.database.MatrixCursor
|
||||||
import android.os.CancellationSignal
|
import android.os.CancellationSignal
|
||||||
import android.os.ParcelFileDescriptor
|
import android.os.ParcelFileDescriptor
|
||||||
import android.provider.DocumentsContract.Document
|
import android.provider.DocumentsContract
|
||||||
import android.provider.DocumentsContract.Root
|
|
||||||
import android.provider.DocumentsProvider
|
import android.provider.DocumentsProvider
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileNotFoundException
|
import java.io.FileNotFoundException
|
||||||
|
|
||||||
|
|
||||||
class FilesProvider : DocumentsProvider() {
|
class FilesProvider : DocumentsProvider() {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val DEFAULT_ROOT_ID = "0"
|
private const val DEFAULT_ROOT_ID = "0"
|
||||||
|
|
||||||
private val DEFAULT_DOCUMENT_COLUMNS = arrayOf(
|
private val DEFAULT_DOCUMENT_COLUMNS = arrayOf(
|
||||||
Document.COLUMN_DOCUMENT_ID,
|
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
|
||||||
Document.COLUMN_DISPLAY_NAME,
|
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
|
||||||
Document.COLUMN_MIME_TYPE,
|
DocumentsContract.Document.COLUMN_MIME_TYPE,
|
||||||
Document.COLUMN_FLAGS,
|
DocumentsContract.Document.COLUMN_FLAGS,
|
||||||
Document.COLUMN_SIZE,
|
DocumentsContract.Document.COLUMN_SIZE,
|
||||||
)
|
)
|
||||||
private val DEFAULT_ROOT_COLUMNS = arrayOf(
|
private val DEFAULT_ROOT_COLUMNS = arrayOf(
|
||||||
Root.COLUMN_ROOT_ID,
|
DocumentsContract.Root.COLUMN_ROOT_ID,
|
||||||
Root.COLUMN_FLAGS,
|
DocumentsContract.Root.COLUMN_FLAGS,
|
||||||
Root.COLUMN_ICON,
|
DocumentsContract.Root.COLUMN_ICON,
|
||||||
Root.COLUMN_TITLE,
|
DocumentsContract.Root.COLUMN_TITLE,
|
||||||
Root.COLUMN_SUMMARY,
|
DocumentsContract.Root.COLUMN_SUMMARY,
|
||||||
Root.COLUMN_DOCUMENT_ID
|
DocumentsContract.Root.COLUMN_DOCUMENT_ID
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,12 +38,12 @@ class FilesProvider : DocumentsProvider() {
|
|||||||
override fun queryRoots(projection: Array<String>?): Cursor {
|
override fun queryRoots(projection: Array<String>?): Cursor {
|
||||||
return MatrixCursor(projection ?: DEFAULT_ROOT_COLUMNS).apply {
|
return MatrixCursor(projection ?: DEFAULT_ROOT_COLUMNS).apply {
|
||||||
newRow().apply {
|
newRow().apply {
|
||||||
add(Root.COLUMN_ROOT_ID, DEFAULT_ROOT_ID)
|
add(DocumentsContract.Root.COLUMN_ROOT_ID, DEFAULT_ROOT_ID)
|
||||||
add(Root.COLUMN_FLAGS, Root.FLAG_LOCAL_ONLY)
|
add(DocumentsContract.Root.COLUMN_FLAGS, DocumentsContract.Root.FLAG_LOCAL_ONLY)
|
||||||
add(Root.COLUMN_ICON, R.mipmap.ic_launcher)
|
add(DocumentsContract.Root.COLUMN_ICON, R.drawable.ic_service)
|
||||||
add(Root.COLUMN_TITLE, context!!.getString(R.string.fl_clash))
|
add(DocumentsContract.Root.COLUMN_TITLE, "FlClash")
|
||||||
add(Root.COLUMN_SUMMARY, "Data")
|
add(DocumentsContract.Root.COLUMN_SUMMARY, "Data")
|
||||||
add(Root.COLUMN_DOCUMENT_ID, "/")
|
add(DocumentsContract.Root.COLUMN_DOCUMENT_ID, "/")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,20 +85,20 @@ class FilesProvider : DocumentsProvider() {
|
|||||||
|
|
||||||
private fun includeFile(result: MatrixCursor, file: File) {
|
private fun includeFile(result: MatrixCursor, file: File) {
|
||||||
result.newRow().apply {
|
result.newRow().apply {
|
||||||
add(Document.COLUMN_DOCUMENT_ID, file.absolutePath)
|
add(DocumentsContract.Document.COLUMN_DOCUMENT_ID, file.absolutePath)
|
||||||
add(Document.COLUMN_DISPLAY_NAME, file.name)
|
add(DocumentsContract.Document.COLUMN_DISPLAY_NAME, file.name)
|
||||||
add(Document.COLUMN_SIZE, file.length())
|
add(DocumentsContract.Document.COLUMN_SIZE, file.length())
|
||||||
add(
|
add(
|
||||||
Document.COLUMN_FLAGS,
|
DocumentsContract.Document.COLUMN_FLAGS,
|
||||||
Document.FLAG_SUPPORTS_WRITE or Document.FLAG_SUPPORTS_DELETE
|
DocumentsContract.Document.FLAG_SUPPORTS_WRITE or DocumentsContract.Document.FLAG_SUPPORTS_DELETE
|
||||||
)
|
)
|
||||||
add(Document.COLUMN_MIME_TYPE, getDocumentType(file))
|
add(DocumentsContract.Document.COLUMN_MIME_TYPE, getDocumentType(file))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getDocumentType(file: File): String {
|
private fun getDocumentType(file: File): String {
|
||||||
return if (file.isDirectory) {
|
return if (file.isDirectory) {
|
||||||
Document.MIME_TYPE_DIR
|
DocumentsContract.Document.MIME_TYPE_DIR
|
||||||
} else {
|
} else {
|
||||||
"application/octet-stream"
|
"application/octet-stream"
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
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,166 @@
|
|||||||
|
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 updateNotificationParams(params: NotificationParams?) {
|
||||||
|
State.notificationParamsFlow.tryEmit(params)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
override fun startService(
|
||||||
|
options: VpnOptions,
|
||||||
|
runtime: Long,
|
||||||
|
result: IResultInterface,
|
||||||
|
) {
|
||||||
|
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,22 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
package com.follow.clash.service
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
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.core.Core
|
||||||
|
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 java.net.InetSocketAddress
|
||||||
|
import android.net.VpnService as SystemVpnService
|
||||||
|
|
||||||
|
class VpnService : SystemVpnService(), IBaseService,
|
||||||
|
CoroutineScope by CoroutineScope(Dispatchers.Default) {
|
||||||
|
|
||||||
|
private val self: VpnService
|
||||||
|
get() = this
|
||||||
|
|
||||||
|
private val loader = moduleLoader {
|
||||||
|
install(NetworkObserveModule(self))
|
||||||
|
install(NotificationModule(self))
|
||||||
|
install(SuspendModule(self))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
handleCreate()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
handleDestroy()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val connectivity by lazy {
|
||||||
|
getSystemService<ConnectivityManager>()
|
||||||
|
}
|
||||||
|
private val uidPageNameMap = mutableMapOf<Int, String>()
|
||||||
|
|
||||||
|
private fun resolverProcess(
|
||||||
|
protocol: Int,
|
||||||
|
source: InetSocketAddress,
|
||||||
|
target: InetSocketAddress,
|
||||||
|
uid: Int,
|
||||||
|
): String {
|
||||||
|
val nextUid = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
connectivity?.getConnectionOwnerUid(protocol, source, target) ?: -1
|
||||||
|
} else {
|
||||||
|
uid
|
||||||
|
}
|
||||||
|
if (nextUid == -1) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if (!uidPageNameMap.containsKey(nextUid)) {
|
||||||
|
uidPageNameMap[nextUid] = this.packageManager?.getPackagesForUid(nextUid)?.first() ?: ""
|
||||||
|
}
|
||||||
|
return uidPageNameMap[nextUid] ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
val VpnOptions.address
|
||||||
|
get(): String = buildString {
|
||||||
|
append(IPV4_ADDRESS)
|
||||||
|
if (ipv6) {
|
||||||
|
append(",")
|
||||||
|
append(IPV6_ADDRESS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val VpnOptions.dns
|
||||||
|
get(): String {
|
||||||
|
if (dnsHijacking) {
|
||||||
|
return NET_ANY
|
||||||
|
}
|
||||||
|
return buildString {
|
||||||
|
append(DNS)
|
||||||
|
if (ipv6) {
|
||||||
|
append(",")
|
||||||
|
append(DNS6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent): IBinder {
|
||||||
|
return binder
|
||||||
|
}
|
||||||
|
|
||||||
|
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.accessControl.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")
|
||||||
|
setBlocking(false)
|
||||||
|
if (Build.VERSION.SDK_INT >= 29) {
|
||||||
|
setMetered(false)
|
||||||
|
}
|
||||||
|
if (options.allowBypass) {
|
||||||
|
allowBypass()
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && options.systemProxy) {
|
||||||
|
GlobalState.log("Open http proxy")
|
||||||
|
setHttpProxy(
|
||||||
|
ProxyInfo.buildDirectProxy(
|
||||||
|
"127.0.0.1", options.port, options.bypassDomain
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
establish()?.detachFd()
|
||||||
|
?: throw NullPointerException("Establish VPN rejected by system")
|
||||||
|
}
|
||||||
|
Core.startTun(
|
||||||
|
fd,
|
||||||
|
protect = this::protect,
|
||||||
|
resolverProcess = this::resolverProcess,
|
||||||
|
options.stack,
|
||||||
|
options.address,
|
||||||
|
options.dns
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun start() {
|
||||||
|
loader.load()
|
||||||
|
State.options?.let {
|
||||||
|
handleStart(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun stop() {
|
||||||
|
loader.cancel()
|
||||||
|
Core.stopTun()
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val IPV4_ADDRESS = "172.19.0.1/30"
|
||||||
|
private const val IPV6_ADDRESS = "fdfe:dcba:9876::1/126"
|
||||||
|
private const val DNS = "172.19.0.2"
|
||||||
|
private const val DNS6 = "fdfe:dcba:9876::2"
|
||||||
|
private const val NET_ANY = "0.0.0.0"
|
||||||
|
private const val NET_ANY6 = "::"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.follow.clash.service.models
|
||||||
|
|
||||||
|
import android.os.Parcelable
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class NotificationParams(
|
||||||
|
val title: String = "FlClash",
|
||||||
|
val stopText: String = "STOP",
|
||||||
|
val onlyStatisticsProxy: Boolean = false,
|
||||||
|
) : Parcelable
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
data class Traffic(
|
||||||
|
val up: Long,
|
||||||
|
val down: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
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 ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package com.follow.clash.service.models
|
||||||
|
|
||||||
|
import android.os.Parcelable
|
||||||
|
import com.follow.clash.common.AccessControlMode
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
import java.net.InetAddress
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class AccessControl(
|
||||||
|
val enable: Boolean,
|
||||||
|
val mode: AccessControlMode,
|
||||||
|
val acceptList: List<String>,
|
||||||
|
val rejectList: List<String>,
|
||||||
|
) : Parcelable
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class VpnOptions(
|
||||||
|
val enable: Boolean,
|
||||||
|
val port: Int,
|
||||||
|
val ipv6: Boolean,
|
||||||
|
val dnsHijacking: Boolean,
|
||||||
|
val accessControl: AccessControl,
|
||||||
|
val allowBypass: Boolean,
|
||||||
|
val systemProxy: Boolean,
|
||||||
|
val bypassDomain: List<String>,
|
||||||
|
val stack: String,
|
||||||
|
val routeAddress: List<String>,
|
||||||
|
) : Parcelable
|
||||||
|
|
||||||
|
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 String.toCIDR(): CIDR {
|
||||||
|
val parts = split("/")
|
||||||
|
if (parts.size != 2) {
|
||||||
|
throw IllegalArgumentException("Invalid CIDR format")
|
||||||
|
}
|
||||||
|
val ipAddress = parts[0]
|
||||||
|
val prefixLength =
|
||||||
|
parts[1].toIntOrNull() ?: throw IllegalArgumentException("Invalid prefix length")
|
||||||
|
|
||||||
|
val address = InetAddress.getByName(ipAddress)
|
||||||
|
|
||||||
|
val maxPrefix = if (address.address.size == 4) 32 else 128
|
||||||
|
if (prefixLength < 0 || prefixLength > maxPrefix) {
|
||||||
|
throw IllegalArgumentException("Invalid prefix length for IP version")
|
||||||
|
}
|
||||||
|
|
||||||
|
return CIDR(address, prefixLength)
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package com.follow.clash.service.modules
|
||||||
|
|
||||||
|
import android.app.Service
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.LinkProperties
|
||||||
|
import android.net.Network
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
import android.net.NetworkCapabilities.TRANSPORT_SATELLITE
|
||||||
|
import android.net.NetworkCapabilities.TRANSPORT_USB
|
||||||
|
import android.net.NetworkRequest
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.content.getSystemService
|
||||||
|
import com.follow.clash.core.Core
|
||||||
|
import java.net.Inet4Address
|
||||||
|
import java.net.Inet6Address
|
||||||
|
import java.net.InetAddress
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
private data class NetworkInfo(
|
||||||
|
@Volatile var losingMs: Long = 0, @Volatile var dnsList: List<InetAddress> = emptyList()
|
||||||
|
) {
|
||||||
|
fun isAvailable(): Boolean = losingMs < System.currentTimeMillis()
|
||||||
|
}
|
||||||
|
|
||||||
|
class NetworkObserveModule(private val service: Service) : Module() {
|
||||||
|
|
||||||
|
private val networkInfos = ConcurrentHashMap<Network, NetworkInfo>()
|
||||||
|
private val connectivity by lazy {
|
||||||
|
service.getSystemService<ConnectivityManager>()
|
||||||
|
}
|
||||||
|
private var preDnsList = listOf<String>()
|
||||||
|
|
||||||
|
private val request = NetworkRequest.Builder().apply {
|
||||||
|
addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
|
||||||
|
addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
|
addCapability(NetworkCapabilities.NET_CAPABILITY_FOREGROUND)
|
||||||
|
}
|
||||||
|
addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED)
|
||||||
|
}.build()
|
||||||
|
|
||||||
|
private val callback = object : ConnectivityManager.NetworkCallback() {
|
||||||
|
override fun onAvailable(network: Network) {
|
||||||
|
networkInfos[network] = NetworkInfo()
|
||||||
|
onUpdateNetwork()
|
||||||
|
super.onAvailable(network)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLosing(network: Network, maxMsToLive: Int) {
|
||||||
|
networkInfos[network]?.losingMs = System.currentTimeMillis() + maxMsToLive
|
||||||
|
onUpdateNetwork()
|
||||||
|
setUnderlyingNetworks(network)
|
||||||
|
super.onLosing(network, maxMsToLive)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLost(network: Network) {
|
||||||
|
networkInfos.remove(network)
|
||||||
|
onUpdateNetwork()
|
||||||
|
setUnderlyingNetworks(network)
|
||||||
|
super.onLost(network)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLinkPropertiesChanged(network: Network, linkProperties: LinkProperties) {
|
||||||
|
networkInfos[network]?.dnsList = linkProperties.dnsServers
|
||||||
|
onUpdateNetwork()
|
||||||
|
setUnderlyingNetworks(network)
|
||||||
|
super.onLinkPropertiesChanged(network, linkProperties)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
override fun onInstall() {
|
||||||
|
onUpdateNetwork()
|
||||||
|
connectivity?.registerNetworkCallback(request, callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun networkToInt(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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
else -> 20
|
||||||
|
} + (if (entry.value.isAvailable()) 0 else 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onUpdateNetwork() {
|
||||||
|
val dnsList = (networkInfos.asSequence().minByOrNull { networkToInt(it) }?.value?.dnsList
|
||||||
|
?: emptyList()).map { x -> x.asSocketAddressText(53) }
|
||||||
|
if (dnsList == preDnsList) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
preDnsList = dnsList
|
||||||
|
Core.updateDNS(dnsList.toSet().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(":")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (address.scopeId > 0) {
|
||||||
|
sb.append("%")
|
||||||
|
sb.append(address.scopeId)
|
||||||
|
}
|
||||||
|
return sb.toString()
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package com.follow.clash.service.modules
|
||||||
|
|
||||||
|
import android.app.Notification.FOREGROUND_SERVICE_IMMEDIATE
|
||||||
|
import android.app.Service
|
||||||
|
import android.app.Service.STOP_FOREGROUND_REMOVE
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.PowerManager
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.content.getSystemService
|
||||||
|
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.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.models.NotificationParams
|
||||||
|
import com.follow.clash.service.models.getSpeedTrafficText
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.onStart
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
data class ExtendedNotificationParams(
|
||||||
|
val title: String,
|
||||||
|
val stopText: String,
|
||||||
|
val onlyStatisticsProxy: Boolean,
|
||||||
|
val contentText: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
val NotificationParams.extended: ExtendedNotificationParams
|
||||||
|
get() = ExtendedNotificationParams(
|
||||||
|
title, stopText, onlyStatisticsProxy, Core.getSpeedTrafficText(onlyStatisticsProxy)
|
||||||
|
)
|
||||||
|
|
||||||
|
class NotificationModule(private val service: Service) : Module() {
|
||||||
|
private val scope = CoroutineScope(Dispatchers.Default)
|
||||||
|
|
||||||
|
override fun onInstall() {
|
||||||
|
scope.launch {
|
||||||
|
val screenFlow = service.receiveBroadcastFlow {
|
||||||
|
addAction(Intent.ACTION_SCREEN_ON)
|
||||||
|
addAction(Intent.ACTION_SCREEN_OFF)
|
||||||
|
}.map { intent ->
|
||||||
|
intent.action == Intent.ACTION_SCREEN_ON
|
||||||
|
}.onStart {
|
||||||
|
emit(isScreenOn())
|
||||||
|
}
|
||||||
|
|
||||||
|
combine(
|
||||||
|
tickerFlow(1000, 0), State.notificationParamsFlow, 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isScreenOn(): Boolean {
|
||||||
|
val pm = service.getSystemService<PowerManager>()
|
||||||
|
return when (pm != null) {
|
||||||
|
true -> pm.isInteractive
|
||||||
|
false -> true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val notificationBuilder: NotificationCompat.Builder by lazy {
|
||||||
|
val intent = Intent().setComponent(Components.MAIN_ACTIVITY)
|
||||||
|
with(
|
||||||
|
NotificationCompat.Builder(
|
||||||
|
service, GlobalState.NOTIFICATION_CHANNEL
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
setSmallIcon(R.drawable.ic)
|
||||||
|
setContentTitle("FlClash")
|
||||||
|
setContentIntent(intent.toPendingIntent)
|
||||||
|
setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
foregroundServiceBehavior = FOREGROUND_SERVICE_IMMEDIATE
|
||||||
|
}
|
||||||
|
setOngoing(true)
|
||||||
|
setShowWhen(true)
|
||||||
|
setOnlyAlertOnce(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun update(params: ExtendedNotificationParams) {
|
||||||
|
service.startForeground(
|
||||||
|
with(notificationBuilder) {
|
||||||
|
setContentTitle(params.title)
|
||||||
|
setContentText(params.contentText)
|
||||||
|
clearActions()
|
||||||
|
addAction(
|
||||||
|
0, params.stopText, QuickAction.STOP.quickIntent.toPendingIntent
|
||||||
|
).build()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onUninstall() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
service.stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
} else {
|
||||||
|
service.stopForeground(true)
|
||||||
|
}
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package com.follow.clash.service.modules
|
||||||
|
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.PowerManager
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class SuspendModule(private val service: Service) : Module() {
|
||||||
|
private val scope = CoroutineScope(Dispatchers.Default)
|
||||||
|
|
||||||
|
private fun isScreenOn(): Boolean {
|
||||||
|
val pm = service.getSystemService<PowerManager>()
|
||||||
|
return when (pm != null) {
|
||||||
|
true -> pm.isInteractive
|
||||||
|
false -> true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
|
scope.launch {
|
||||||
|
val screenFlow = service.receiveBroadcastFlow {
|
||||||
|
addAction(Intent.ACTION_SCREEN_ON)
|
||||||
|
addAction(Intent.ACTION_SCREEN_OFF)
|
||||||
|
}.map { intent ->
|
||||||
|
intent.action == Intent.ACTION_SCREEN_ON
|
||||||
|
}.onStart {
|
||||||
|
emit(isScreenOn())
|
||||||
|
}
|
||||||
|
|
||||||
|
screenFlow.collect {
|
||||||
|
onUpdate(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onUninstall() {
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
17
android/service/src/main/res/drawable/ic.xml
Normal file
17
android/service/src/main/res/drawable/ic.xml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:width="240dp"
|
||||||
|
android:height="240dp"
|
||||||
|
android:viewportWidth="240"
|
||||||
|
android:viewportHeight="240"
|
||||||
|
tools:ignore="VectorRaster">
|
||||||
|
<path
|
||||||
|
android:pathData="M48.1,80.89L168.44,11.41c11.08,-6.4 25.24,-2.6 31.64,8.48 0,0 0,0 0,0h0c6.4,11.08 2.6,25.24 -8.48,31.64 0,0 0,0 0,0l-120.34,69.48c-11.08,6.4 -25.24,2.6 -31.64,-8.48 0,0 0,0 0,0h0c-6.4,-11.08 -2.6,-25.24 8.48,-31.64 0,0 0,0 0,0Z"
|
||||||
|
android:fillColor="#FFFFFF"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M78.98,134.37l60.18,-34.74c11.07,-6.39 25.23,-2.59 31.63,8.48h0c6.4,11.07 2.61,25.24 -8.47,31.64l-60.18,34.74c-11.08,6.4 -25.24,2.6 -31.64,-8.48 0,0 0,0 0,0h0c-6.4,-11.08 -2.6,-25.24 8.48,-31.64h0Z"
|
||||||
|
android:fillColor="#FFFFFF"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M109.86,187.86h0c11.08,-6.4 25.24,-2.6 31.64,8.48 0,0 0,0 0,0h0c6.4,11.08 2.6,25.24 -8.48,31.64 0,0 0,0 0,0h0c-11.08,6.4 -25.24,2.6 -31.64,-8.48 0,0 0,0 0,0h0c-6.4,-11.08 -2.6,-25.24 8.48,-31.64 0,0 0,0 0,0Z"
|
||||||
|
android:fillColor="#FFFFFF"/>
|
||||||
|
</vector>
|
||||||
17
android/service/src/main/res/drawable/ic_service.xml
Normal file
17
android/service/src/main/res/drawable/ic_service.xml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:width="240dp"
|
||||||
|
android:height="240dp"
|
||||||
|
android:viewportWidth="240"
|
||||||
|
android:viewportHeight="240"
|
||||||
|
tools:ignore="VectorRaster">
|
||||||
|
<path
|
||||||
|
android:pathData="M48.1,80.89L168.44,11.41c11.08,-6.4 25.24,-2.6 31.64,8.48 0,0 0,0 0,0h0c6.4,11.08 2.6,25.24 -8.48,31.64 0,0 0,0 0,0l-120.34,69.48c-11.08,6.4 -25.24,2.6 -31.64,-8.48 0,0 0,0 0,0h0c-6.4,-11.08 -2.6,-25.24 8.48,-31.64 0,0 0,0 0,0Z"
|
||||||
|
android:fillColor="#6666FB"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M78.98,134.37l60.18,-34.74c11.07,-6.39 25.23,-2.59 31.63,8.48h0c6.4,11.07 2.61,25.24 -8.47,31.64l-60.18,34.74c-11.08,6.4 -25.24,2.6 -31.64,-8.48 0,0 0,0 0,0h0c-6.4,-11.08 -2.6,-25.24 8.48,-31.64h0Z"
|
||||||
|
android:fillColor="#336AB6"/>
|
||||||
|
<path
|
||||||
|
android:pathData="M109.86,187.86h0c11.08,-6.4 25.24,-2.6 31.64,8.48 0,0 0,0 0,0h0c6.4,11.08 2.6,25.24 -8.48,31.64 0,0 0,0 0,0h0c-11.08,6.4 -25.24,2.6 -31.64,-8.48 0,0 0,0 0,0h0c-6.4,-11.08 -2.6,-25.24 8.48,-31.64 0,0 0,0 0,0Z"
|
||||||
|
android:fillColor="#5CA8E9"/>
|
||||||
|
</vector>
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
pluginManagement {
|
|
||||||
def flutterSdkPath = {
|
|
||||||
def properties = new Properties()
|
|
||||||
file("local.properties").withInputStream { properties.load(it) }
|
|
||||||
def flutterSdkPath = properties.getProperty("flutter.sdk")
|
|
||||||
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
|
|
||||||
return flutterSdkPath
|
|
||||||
}
|
|
||||||
settings.ext.flutterSdkPath = flutterSdkPath()
|
|
||||||
|
|
||||||
includeBuild("${settings.ext.flutterSdkPath}/packages/flutter_tools/gradle")
|
|
||||||
|
|
||||||
repositories {
|
|
||||||
google()
|
|
||||||
mavenCentral()
|
|
||||||
gradlePluginPortal()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
plugins {
|
|
||||||
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
|
||||||
id "com.android.application" version "$agp_version" apply false
|
|
||||||
id "org.jetbrains.kotlin.android" version "$kotlin_version" apply false
|
|
||||||
}
|
|
||||||
|
|
||||||
include ":app"
|
|
||||||
include ':core'
|
|
||||||
31
android/settings.gradle.kts
Normal file
31
android/settings.gradle.kts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
pluginManagement {
|
||||||
|
val flutterSdkPath = run {
|
||||||
|
val properties = java.util.Properties()
|
||||||
|
file("local.properties").inputStream().use { properties.load(it) }
|
||||||
|
val flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||||
|
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
|
||||||
|
flutterSdkPath
|
||||||
|
}
|
||||||
|
|
||||||
|
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||||
|
id("com.android.application") version "8.12.2" apply false
|
||||||
|
id("org.jetbrains.kotlin.android") version "2.2.10" apply false
|
||||||
|
id("com.google.gms.google-services") version ("4.3.15") apply false
|
||||||
|
id("com.google.firebase.crashlytics") version ("2.8.1") apply false
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
include(":app")
|
||||||
|
include(":core")
|
||||||
|
include(":service")
|
||||||
|
include(":common")
|
||||||
@@ -13,7 +13,6 @@
|
|||||||
"resourcesDesc": "External resource related info",
|
"resourcesDesc": "External resource related info",
|
||||||
"trafficUsage": "Traffic usage",
|
"trafficUsage": "Traffic usage",
|
||||||
"coreInfo": "Core info",
|
"coreInfo": "Core info",
|
||||||
"nullCoreInfoDesc": "Unable to obtain core info",
|
|
||||||
"networkSpeed": "Network speed",
|
"networkSpeed": "Network speed",
|
||||||
"outboundMode": "Outbound mode",
|
"outboundMode": "Outbound mode",
|
||||||
"networkDetection": "Network detection",
|
"networkDetection": "Network detection",
|
||||||
@@ -22,7 +21,6 @@
|
|||||||
"noProxy": "No proxy",
|
"noProxy": "No proxy",
|
||||||
"noProxyDesc": "Please create a profile or add a valid profile",
|
"noProxyDesc": "Please create a profile or add a valid profile",
|
||||||
"nullProfileDesc": "No profile, Please add a profile",
|
"nullProfileDesc": "No profile, Please add a profile",
|
||||||
"nullLogsDesc": "No logs",
|
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"defaultText": "Default",
|
"defaultText": "Default",
|
||||||
@@ -149,8 +147,6 @@
|
|||||||
"addressHelp": "WebDAV server address",
|
"addressHelp": "WebDAV server address",
|
||||||
"addressTip": "Please enter a valid WebDAV address",
|
"addressTip": "Please enter a valid WebDAV address",
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
"passwordTip": "Password cannot be empty",
|
|
||||||
"accountTip": "Account cannot be empty",
|
|
||||||
"checkUpdate": "Check for updates",
|
"checkUpdate": "Check for updates",
|
||||||
"discoverNewVersion": "Discover the new version",
|
"discoverNewVersion": "Discover the new version",
|
||||||
"checkUpdateError": "The current application is already the latest version",
|
"checkUpdateError": "The current application is already the latest version",
|
||||||
@@ -185,8 +181,6 @@
|
|||||||
"expirationTime": "Expiration time",
|
"expirationTime": "Expiration time",
|
||||||
"connections": "Connections",
|
"connections": "Connections",
|
||||||
"connectionsDesc": "View current connections data",
|
"connectionsDesc": "View current connections data",
|
||||||
"nullRequestsDesc": "No requests",
|
|
||||||
"nullConnectionsDesc": "No connections",
|
|
||||||
"intranetIP": "Intranet IP",
|
"intranetIP": "Intranet IP",
|
||||||
"view": "View",
|
"view": "View",
|
||||||
"cut": "Cut",
|
"cut": "Cut",
|
||||||
@@ -219,7 +213,6 @@
|
|||||||
"autoCloseConnectionsDesc": "Auto close connections after change node",
|
"autoCloseConnectionsDesc": "Auto close connections after change node",
|
||||||
"onlyStatisticsProxy": "Only statistics proxy",
|
"onlyStatisticsProxy": "Only statistics proxy",
|
||||||
"onlyStatisticsProxyDesc": "When turned on, only statistics proxy traffic",
|
"onlyStatisticsProxyDesc": "When turned on, only statistics proxy traffic",
|
||||||
"deleteProfileTip": "Sure you want to delete the current profile?",
|
|
||||||
"pureBlackMode": "Pure black mode",
|
"pureBlackMode": "Pure black mode",
|
||||||
"keepAliveIntervalDesc": "Tcp keep alive interval",
|
"keepAliveIntervalDesc": "Tcp keep alive interval",
|
||||||
"entries": " entries",
|
"entries": " entries",
|
||||||
@@ -250,7 +243,6 @@
|
|||||||
"dnsDesc": "Update DNS related settings",
|
"dnsDesc": "Update DNS related settings",
|
||||||
"key": "Key",
|
"key": "Key",
|
||||||
"value": "Value",
|
"value": "Value",
|
||||||
"notEmpty": "Cannot be empty",
|
|
||||||
"hostsDesc": "Add Hosts",
|
"hostsDesc": "Add Hosts",
|
||||||
"vpnTip": "Changes take effect after restarting the VPN",
|
"vpnTip": "Changes take effect after restarting the VPN",
|
||||||
"vpnEnableDesc": "Auto routes all system traffic through VpnService",
|
"vpnEnableDesc": "Auto routes all system traffic through VpnService",
|
||||||
@@ -337,15 +329,12 @@
|
|||||||
"fileIsUpdate": "The file has been modified. Do you want to save the changes?",
|
"fileIsUpdate": "The file has been modified. Do you want to save the changes?",
|
||||||
"profileHasUpdate": "The profile has been modified. Do you want to disable auto update?",
|
"profileHasUpdate": "The profile has been modified. Do you want to disable auto update?",
|
||||||
"hasCacheChange": "Do you want to cache the changes?",
|
"hasCacheChange": "Do you want to cache the changes?",
|
||||||
"nullProxies": "No proxies",
|
|
||||||
"copySuccess": "Copy success",
|
"copySuccess": "Copy success",
|
||||||
"copyLink": "Copy link",
|
"copyLink": "Copy link",
|
||||||
"exportFile": "Export file",
|
"exportFile": "Export file",
|
||||||
"cacheCorrupt": "The cache is corrupt. Do you want to clear it?",
|
"cacheCorrupt": "The cache is corrupt. Do you want to clear it?",
|
||||||
"detectionTip": "Relying on third-party api is for reference only",
|
"detectionTip": "Relying on third-party api is for reference only",
|
||||||
"listen": "Listen",
|
"listen": "Listen",
|
||||||
"keyExists": "The current key already exists",
|
|
||||||
"valueExists": "The current value already exists",
|
|
||||||
"undo": "undo",
|
"undo": "undo",
|
||||||
"redo": "redo",
|
"redo": "redo",
|
||||||
"none": "none",
|
"none": "none",
|
||||||
@@ -353,28 +342,21 @@
|
|||||||
"basicConfigDesc": "Modify the basic configuration globally",
|
"basicConfigDesc": "Modify the basic configuration globally",
|
||||||
"selectedCountTitle": "{count} items have been selected",
|
"selectedCountTitle": "{count} items have been selected",
|
||||||
"addRule": "Add rule",
|
"addRule": "Add rule",
|
||||||
"ruleProviderEmptyTip": "Rule provider cannot be empty",
|
|
||||||
"ruleName": "Rule name",
|
"ruleName": "Rule name",
|
||||||
"content": "Content",
|
"content": "Content",
|
||||||
"contentEmptyTip": "Content cannot be empty",
|
|
||||||
"subRule": "Sub rule",
|
"subRule": "Sub rule",
|
||||||
"subRuleEmptyTip": "Sub rule content cannot be empty",
|
|
||||||
"ruleTarget": "Rule target",
|
"ruleTarget": "Rule target",
|
||||||
"ruleTargetEmptyTip": "Rule target cannot be empty",
|
|
||||||
"sourceIp": "Source IP",
|
"sourceIp": "Source IP",
|
||||||
"noResolve": "No resolve IP",
|
"noResolve": "No resolve IP",
|
||||||
"getOriginRules": "Get original rules",
|
"getOriginRules": "Get original rules",
|
||||||
"overrideOriginRules": "Override the original rule",
|
"overrideOriginRules": "Override the original rule",
|
||||||
"addedOriginRules": "Attach on the original rules",
|
"addedOriginRules": "Attach on the original rules",
|
||||||
"enableOverride": "Enable override",
|
"enableOverride": "Enable override",
|
||||||
"deleteRuleTip": "Are you sure you want to delete the selected rule?",
|
|
||||||
"saveChanges": "Do you want to save the changes?",
|
"saveChanges": "Do you want to save the changes?",
|
||||||
"generalDesc": "Modify general settings",
|
"generalDesc": "Modify general settings",
|
||||||
"findProcessModeDesc": "There is a certain performance loss after opening",
|
"findProcessModeDesc": "There is a certain performance loss after opening",
|
||||||
"tabAnimationDesc": "Effective only in mobile view",
|
"tabAnimationDesc": "Effective only in mobile view",
|
||||||
"saveTip": "Are you sure you want to save?",
|
"saveTip": "Are you sure you want to save?",
|
||||||
"deleteColorTip": "Are you sure you want to delete the current color?",
|
|
||||||
"colorExists": "Current color already exists",
|
|
||||||
"colorSchemes": "Color schemes",
|
"colorSchemes": "Color schemes",
|
||||||
"palette": "Palette",
|
"palette": "Palette",
|
||||||
"tonalSpotScheme": "TonalSpot",
|
"tonalSpotScheme": "TonalSpot",
|
||||||
@@ -400,5 +382,55 @@
|
|||||||
"recoveryStrategy": "Recovery strategy",
|
"recoveryStrategy": "Recovery strategy",
|
||||||
"recoveryStrategy_override": "Override",
|
"recoveryStrategy_override": "Override",
|
||||||
"recoveryStrategy_compatible": "Compatible",
|
"recoveryStrategy_compatible": "Compatible",
|
||||||
"logsTest": "Logs test"
|
"logsTest": "Logs test",
|
||||||
|
"emptyTip": "{label} cannot be empty",
|
||||||
|
"urlTip": "{label} must be a url",
|
||||||
|
"numberTip": "{label} must be a number",
|
||||||
|
"interval": "Interval",
|
||||||
|
"existsTip": "Current {label} already exists",
|
||||||
|
"deleteTip": "Are you sure you want to delete the current {label}?",
|
||||||
|
"deleteMultipTip": "Are you sure you want to delete the selected {label}?",
|
||||||
|
"nullTip": "No {label} at the moment",
|
||||||
|
"script": "Script",
|
||||||
|
"color": "Color",
|
||||||
|
"rename": "Rename",
|
||||||
|
"unnamed": "Unnamed",
|
||||||
|
"pleaseEnterScriptName": "Please enter a script name",
|
||||||
|
"overrideInvalidTip": "Does not take effect in script mode",
|
||||||
|
"mixedPort": "Mixed Port",
|
||||||
|
"socksPort": "Socks Port",
|
||||||
|
"redirPort": "Redir Port",
|
||||||
|
"tproxyPort": "Tproxy Port",
|
||||||
|
"portTip": "{label} must be between 1024 and 49151",
|
||||||
|
"portConflictTip": "Please enter a different port",
|
||||||
|
"import": "Import",
|
||||||
|
"importFile": "Import from file",
|
||||||
|
"importUrl": "Import from URL",
|
||||||
|
"autoSetSystemDns": "Auto set system DNS",
|
||||||
|
"details": "{label} details",
|
||||||
|
"creationTime": "Creation time",
|
||||||
|
"process": "Process",
|
||||||
|
"host": "Host",
|
||||||
|
"destination": "Destination",
|
||||||
|
"destinationGeoIP": "Destination GeoIP",
|
||||||
|
"destinationIPASN": "Destination IPASN",
|
||||||
|
"specialProxy": "Special proxy",
|
||||||
|
"specialRules": "special rules",
|
||||||
|
"remoteDestination": "Remote destination",
|
||||||
|
"networkType": "Network type",
|
||||||
|
"proxyChains": "Proxy chains",
|
||||||
|
"log": "Log",
|
||||||
|
"connection": "Connection",
|
||||||
|
"request": "Request",
|
||||||
|
"connected": "Connected",
|
||||||
|
"disconnected": "Disconnected",
|
||||||
|
"connecting": "Connecting...",
|
||||||
|
"restartCoreTip": "Are you sure you want to restart the core?",
|
||||||
|
"forceRestartCoreTip": "Are you sure you want to force restart the core?",
|
||||||
|
"dnsHijacking": "DNS hijacking",
|
||||||
|
"coreStatus": "Core status",
|
||||||
|
"dataCollectionTip": "Data Collection Notice",
|
||||||
|
"dataCollectionContent": "This app uses Firebase Crashlytics to collect crash information to improve app stability.\nThe collected data includes device information and crash details, but does not contain personal sensitive data.\nYou can disable this feature in settings.",
|
||||||
|
"crashlytics": "Crash Analysis",
|
||||||
|
"crashlyticsTip": "When enabled, automatically uploads crash logs without sensitive information when the app crashes"
|
||||||
}
|
}
|
||||||
@@ -13,7 +13,6 @@
|
|||||||
"resourcesDesc": "外部リソース関連情報",
|
"resourcesDesc": "外部リソース関連情報",
|
||||||
"trafficUsage": "トラフィック使用量",
|
"trafficUsage": "トラフィック使用量",
|
||||||
"coreInfo": "コア情報",
|
"coreInfo": "コア情報",
|
||||||
"nullCoreInfoDesc": "コア情報を取得できません",
|
|
||||||
"networkSpeed": "ネットワーク速度",
|
"networkSpeed": "ネットワーク速度",
|
||||||
"outboundMode": "アウトバウンドモード",
|
"outboundMode": "アウトバウンドモード",
|
||||||
"networkDetection": "ネットワーク検出",
|
"networkDetection": "ネットワーク検出",
|
||||||
@@ -22,7 +21,6 @@
|
|||||||
"noProxy": "プロキシなし",
|
"noProxy": "プロキシなし",
|
||||||
"noProxyDesc": "プロファイルを作成するか、有効なプロファイルを追加してください",
|
"noProxyDesc": "プロファイルを作成するか、有効なプロファイルを追加してください",
|
||||||
"nullProfileDesc": "プロファイルがありません。追加してください",
|
"nullProfileDesc": "プロファイルがありません。追加してください",
|
||||||
"nullLogsDesc": "ログがありません",
|
|
||||||
"settings": "設定",
|
"settings": "設定",
|
||||||
"language": "言語",
|
"language": "言語",
|
||||||
"defaultText": "デフォルト",
|
"defaultText": "デフォルト",
|
||||||
@@ -149,8 +147,6 @@
|
|||||||
"addressHelp": "WebDAVサーバーアドレス",
|
"addressHelp": "WebDAVサーバーアドレス",
|
||||||
"addressTip": "有効なWebDAVアドレスを入力",
|
"addressTip": "有効なWebDAVアドレスを入力",
|
||||||
"password": "パスワード",
|
"password": "パスワード",
|
||||||
"passwordTip": "パスワードは必須です",
|
|
||||||
"accountTip": "アカウントは必須です",
|
|
||||||
"checkUpdate": "更新を確認",
|
"checkUpdate": "更新を確認",
|
||||||
"discoverNewVersion": "新バージョンを発見",
|
"discoverNewVersion": "新バージョンを発見",
|
||||||
"checkUpdateError": "アプリは最新版です",
|
"checkUpdateError": "アプリは最新版です",
|
||||||
@@ -185,8 +181,6 @@
|
|||||||
"expirationTime": "有効期限",
|
"expirationTime": "有効期限",
|
||||||
"connections": "接続",
|
"connections": "接続",
|
||||||
"connectionsDesc": "現在の接続データを表示",
|
"connectionsDesc": "現在の接続データを表示",
|
||||||
"nullRequestsDesc": "リクエストなし",
|
|
||||||
"nullConnectionsDesc": "接続なし",
|
|
||||||
"intranetIP": "イントラネットIP",
|
"intranetIP": "イントラネットIP",
|
||||||
"view": "表示",
|
"view": "表示",
|
||||||
"cut": "切り取り",
|
"cut": "切り取り",
|
||||||
@@ -219,7 +213,6 @@
|
|||||||
"autoCloseConnectionsDesc": "ノード変更後に接続を自動閉じる",
|
"autoCloseConnectionsDesc": "ノード変更後に接続を自動閉じる",
|
||||||
"onlyStatisticsProxy": "プロキシのみ統計",
|
"onlyStatisticsProxy": "プロキシのみ統計",
|
||||||
"onlyStatisticsProxyDesc": "有効化するとプロキシトラフィックのみ統計",
|
"onlyStatisticsProxyDesc": "有効化するとプロキシトラフィックのみ統計",
|
||||||
"deleteProfileTip": "現在のプロファイルを削除しますか?",
|
|
||||||
"pureBlackMode": "純黒モード",
|
"pureBlackMode": "純黒モード",
|
||||||
"keepAliveIntervalDesc": "TCPキープアライブ間隔",
|
"keepAliveIntervalDesc": "TCPキープアライブ間隔",
|
||||||
"entries": " エントリ",
|
"entries": " エントリ",
|
||||||
@@ -250,7 +243,6 @@
|
|||||||
"dnsDesc": "DNS関連設定の更新",
|
"dnsDesc": "DNS関連設定の更新",
|
||||||
"key": "キー",
|
"key": "キー",
|
||||||
"value": "値",
|
"value": "値",
|
||||||
"notEmpty": "空欄不可",
|
|
||||||
"hostsDesc": "ホストを追加",
|
"hostsDesc": "ホストを追加",
|
||||||
"vpnTip": "変更はVPN再起動後に有効",
|
"vpnTip": "変更はVPN再起動後に有効",
|
||||||
"vpnEnableDesc": "VpnService経由で全システムトラフィックをルーティング",
|
"vpnEnableDesc": "VpnService経由で全システムトラフィックをルーティング",
|
||||||
@@ -337,15 +329,12 @@
|
|||||||
"fileIsUpdate": "ファイルが変更されました。保存しますか?",
|
"fileIsUpdate": "ファイルが変更されました。保存しますか?",
|
||||||
"profileHasUpdate": "プロファイルが変更されました。自動更新を無効化しますか?",
|
"profileHasUpdate": "プロファイルが変更されました。自動更新を無効化しますか?",
|
||||||
"hasCacheChange": "変更をキャッシュしますか?",
|
"hasCacheChange": "変更をキャッシュしますか?",
|
||||||
"nullProxies": "プロキシなし",
|
|
||||||
"copySuccess": "コピー成功",
|
"copySuccess": "コピー成功",
|
||||||
"copyLink": "リンクをコピー",
|
"copyLink": "リンクをコピー",
|
||||||
"exportFile": "ファイルをエクスポート",
|
"exportFile": "ファイルをエクスポート",
|
||||||
"cacheCorrupt": "キャッシュが破損しています。クリアしますか?",
|
"cacheCorrupt": "キャッシュが破損しています。クリアしますか?",
|
||||||
"detectionTip": "サードパーティAPIに依存(参考値)",
|
"detectionTip": "サードパーティAPIに依存(参考値)",
|
||||||
"listen": "リスン",
|
"listen": "リスン",
|
||||||
"keyExists": "現在のキーは既に存在します",
|
|
||||||
"valueExists": "現在の値は既に存在します",
|
|
||||||
"undo": "元に戻す",
|
"undo": "元に戻す",
|
||||||
"redo": "やり直す",
|
"redo": "やり直す",
|
||||||
"none": "なし",
|
"none": "なし",
|
||||||
@@ -353,28 +342,21 @@
|
|||||||
"basicConfigDesc": "基本設定をグローバルに変更",
|
"basicConfigDesc": "基本設定をグローバルに変更",
|
||||||
"selectedCountTitle": "{count} 項目が選択されています",
|
"selectedCountTitle": "{count} 項目が選択されています",
|
||||||
"addRule": "ルールを追加",
|
"addRule": "ルールを追加",
|
||||||
"ruleProviderEmptyTip": "ルールプロバイダーは必須です",
|
|
||||||
"ruleName": "ルール名",
|
"ruleName": "ルール名",
|
||||||
"content": "内容",
|
"content": "内容",
|
||||||
"contentEmptyTip": "内容は必須です",
|
|
||||||
"subRule": "サブルール",
|
"subRule": "サブルール",
|
||||||
"subRuleEmptyTip": "サブルールの内容は必須です",
|
|
||||||
"ruleTarget": "ルール対象",
|
"ruleTarget": "ルール対象",
|
||||||
"ruleTargetEmptyTip": "ルール対象は必須です",
|
|
||||||
"sourceIp": "送信元IP",
|
"sourceIp": "送信元IP",
|
||||||
"noResolve": "IPを解決しない",
|
"noResolve": "IPを解決しない",
|
||||||
"getOriginRules": "元のルールを取得",
|
"getOriginRules": "元のルールを取得",
|
||||||
"overrideOriginRules": "元のルールを上書き",
|
"overrideOriginRules": "元のルールを上書き",
|
||||||
"addedOriginRules": "元のルールに追加",
|
"addedOriginRules": "元のルールに追加",
|
||||||
"enableOverride": "上書きを有効化",
|
"enableOverride": "上書きを有効化",
|
||||||
"deleteRuleTip": "選択したルールを削除しますか?",
|
|
||||||
"saveChanges": "変更を保存しますか?",
|
"saveChanges": "変更を保存しますか?",
|
||||||
"generalDesc": "一般設定を変更",
|
"generalDesc": "一般設定を変更",
|
||||||
"findProcessModeDesc": "有効化するとパフォーマンスが若干低下します",
|
"findProcessModeDesc": "有効化するとパフォーマンスが若干低下します",
|
||||||
"tabAnimationDesc": "モバイル表示でのみ有効",
|
"tabAnimationDesc": "モバイル表示でのみ有効",
|
||||||
"saveTip": "保存してもよろしいですか?",
|
"saveTip": "保存してもよろしいですか?",
|
||||||
"deleteColorTip": "現在の色を削除しますか?",
|
|
||||||
"colorExists": "この色は既に存在します",
|
|
||||||
"colorSchemes": "カラースキーム",
|
"colorSchemes": "カラースキーム",
|
||||||
"palette": "パレット",
|
"palette": "パレット",
|
||||||
"tonalSpotScheme": "トーンスポット",
|
"tonalSpotScheme": "トーンスポット",
|
||||||
@@ -401,5 +383,55 @@
|
|||||||
"recoveryStrategy": "リカバリー戦略",
|
"recoveryStrategy": "リカバリー戦略",
|
||||||
"recoveryStrategy_override": "オーバーライド",
|
"recoveryStrategy_override": "オーバーライド",
|
||||||
"recoveryStrategy_compatible": "互換性",
|
"recoveryStrategy_compatible": "互換性",
|
||||||
"logsTest": "ログテスト"
|
"logsTest": "ログテスト",
|
||||||
|
"emptyTip": "{label}は空欄にできません",
|
||||||
|
"urlTip": "{label}はURLである必要があります",
|
||||||
|
"numberTip": "{label}は数字でなければなりません",
|
||||||
|
"interval": "インターバル",
|
||||||
|
"existsTip": "現在の{label}は既に存在しています",
|
||||||
|
"deleteTip": "現在の{label}を削除してもよろしいですか?",
|
||||||
|
"deleteMultipTip": "選択された{label}を削除してもよろしいですか?",
|
||||||
|
"nullTip": "現在{label}はありません",
|
||||||
|
"script": "スクリプト",
|
||||||
|
"color": "カラー",
|
||||||
|
"rename": "リネーム",
|
||||||
|
"unnamed": "無題",
|
||||||
|
"pleaseEnterScriptName": "スクリプト名を入力してください",
|
||||||
|
"overrideInvalidTip": "スクリプトモードでは有効になりません",
|
||||||
|
"mixedPort": "混合ポート",
|
||||||
|
"socksPort": "Socksポート",
|
||||||
|
"redirPort": "Redirポート",
|
||||||
|
"tproxyPort": "Tproxyポート",
|
||||||
|
"portTip": "{label} は 1024 から 49151 の間でなければなりません",
|
||||||
|
"portConflictTip": "別のポートを入力してください",
|
||||||
|
"import": "インポート",
|
||||||
|
"importFile": "ファイルからインポート",
|
||||||
|
"importUrl": "URLからインポート",
|
||||||
|
"autoSetSystemDns": "オートセットシステムDNS",
|
||||||
|
"details": "{label}詳細",
|
||||||
|
"creationTime": "作成時間",
|
||||||
|
"process": "プロセス",
|
||||||
|
"host": "ホスト",
|
||||||
|
"destination": "宛先",
|
||||||
|
"destinationGeoIP": "宛先地理情報",
|
||||||
|
"destinationIPASN": "宛先IP ASN",
|
||||||
|
"specialProxy": "特殊プロキシ",
|
||||||
|
"specialRules": "特殊ルール",
|
||||||
|
"remoteDestination": "リモート宛先",
|
||||||
|
"networkType": "ネットワーク種別",
|
||||||
|
"proxyChains": "プロキシチェーン",
|
||||||
|
"log": "ログ",
|
||||||
|
"connection": "接続",
|
||||||
|
"request": "リクエスト",
|
||||||
|
"connected": "接続済み",
|
||||||
|
"disconnected": "切断済み",
|
||||||
|
"connecting": "接続中...",
|
||||||
|
"restartCoreTip": "コアを再起動してもよろしいですか?",
|
||||||
|
"forceRestartCoreTip": "コアを強制再起動してもよろしいですか?",
|
||||||
|
"dnsHijacking": "DNSハイジャッキング",
|
||||||
|
"coreStatus": "コアステータス",
|
||||||
|
"dataCollectionTip": "データ収集説明",
|
||||||
|
"dataCollectionContent": "本アプリはFirebase Crashlyticsを使用してクラッシュ情報を収集し、アプリの安定性を向上させます。\n収集されるデータにはデバイス情報とクラッシュ詳細が含まれますが、個人の機密データは含まれません。\n設定でこの機能を無効にすることができます。",
|
||||||
|
"crashlytics": "クラッシュ分析",
|
||||||
|
"crashlyticsTip": "有効にすると、アプリがクラッシュした際に機密情報を含まないクラッシュログを自動的にアップロードします"
|
||||||
}
|
}
|
||||||
@@ -13,7 +13,6 @@
|
|||||||
"resourcesDesc": "Информация, связанная с внешними ресурсами",
|
"resourcesDesc": "Информация, связанная с внешними ресурсами",
|
||||||
"trafficUsage": "Использование трафика",
|
"trafficUsage": "Использование трафика",
|
||||||
"coreInfo": "Информация о ядре",
|
"coreInfo": "Информация о ядре",
|
||||||
"nullCoreInfoDesc": "Не удалось получить информацию о ядре",
|
|
||||||
"networkSpeed": "Скорость сети",
|
"networkSpeed": "Скорость сети",
|
||||||
"outboundMode": "Режим исходящего трафика",
|
"outboundMode": "Режим исходящего трафика",
|
||||||
"networkDetection": "Обнаружение сети",
|
"networkDetection": "Обнаружение сети",
|
||||||
@@ -22,7 +21,6 @@
|
|||||||
"noProxy": "Нет прокси",
|
"noProxy": "Нет прокси",
|
||||||
"noProxyDesc": "Пожалуйста, создайте профиль или добавьте действительный профиль",
|
"noProxyDesc": "Пожалуйста, создайте профиль или добавьте действительный профиль",
|
||||||
"nullProfileDesc": "Нет профиля, пожалуйста, добавьте профиль",
|
"nullProfileDesc": "Нет профиля, пожалуйста, добавьте профиль",
|
||||||
"nullLogsDesc": "Нет логов",
|
|
||||||
"settings": "Настройки",
|
"settings": "Настройки",
|
||||||
"language": "Язык",
|
"language": "Язык",
|
||||||
"defaultText": "По умолчанию",
|
"defaultText": "По умолчанию",
|
||||||
@@ -149,8 +147,6 @@
|
|||||||
"addressHelp": "Адрес сервера WebDAV",
|
"addressHelp": "Адрес сервера WebDAV",
|
||||||
"addressTip": "Пожалуйста, введите действительный адрес WebDAV",
|
"addressTip": "Пожалуйста, введите действительный адрес WebDAV",
|
||||||
"password": "Пароль",
|
"password": "Пароль",
|
||||||
"passwordTip": "Пароль не может быть пустым",
|
|
||||||
"accountTip": "Аккаунт не может быть пустым",
|
|
||||||
"checkUpdate": "Проверить обновления",
|
"checkUpdate": "Проверить обновления",
|
||||||
"discoverNewVersion": "Обнаружена новая версия",
|
"discoverNewVersion": "Обнаружена новая версия",
|
||||||
"checkUpdateError": "Текущее приложение уже является последней версией",
|
"checkUpdateError": "Текущее приложение уже является последней версией",
|
||||||
@@ -185,8 +181,6 @@
|
|||||||
"expirationTime": "Время истечения",
|
"expirationTime": "Время истечения",
|
||||||
"connections": "Соединения",
|
"connections": "Соединения",
|
||||||
"connectionsDesc": "Просмотр текущих данных о соединениях",
|
"connectionsDesc": "Просмотр текущих данных о соединениях",
|
||||||
"nullRequestsDesc": "Нет запросов",
|
|
||||||
"nullConnectionsDesc": "Нет соединений",
|
|
||||||
"intranetIP": "Внутренний IP",
|
"intranetIP": "Внутренний IP",
|
||||||
"view": "Просмотр",
|
"view": "Просмотр",
|
||||||
"cut": "Вырезать",
|
"cut": "Вырезать",
|
||||||
@@ -219,7 +213,6 @@
|
|||||||
"autoCloseConnectionsDesc": "Автоматически закрывать соединения после смены узла",
|
"autoCloseConnectionsDesc": "Автоматически закрывать соединения после смены узла",
|
||||||
"onlyStatisticsProxy": "Только статистика прокси",
|
"onlyStatisticsProxy": "Только статистика прокси",
|
||||||
"onlyStatisticsProxyDesc": "При включении будет учитываться только трафик прокси",
|
"onlyStatisticsProxyDesc": "При включении будет учитываться только трафик прокси",
|
||||||
"deleteProfileTip": "Вы уверены, что хотите удалить текущий профиль?",
|
|
||||||
"pureBlackMode": "Чисто черный режим",
|
"pureBlackMode": "Чисто черный режим",
|
||||||
"keepAliveIntervalDesc": "Интервал поддержания TCP-соединения",
|
"keepAliveIntervalDesc": "Интервал поддержания TCP-соединения",
|
||||||
"entries": " записей",
|
"entries": " записей",
|
||||||
@@ -250,7 +243,6 @@
|
|||||||
"dnsDesc": "Обновление настроек, связанных с DNS",
|
"dnsDesc": "Обновление настроек, связанных с DNS",
|
||||||
"key": "Ключ",
|
"key": "Ключ",
|
||||||
"value": "Значение",
|
"value": "Значение",
|
||||||
"notEmpty": "Не может быть пустым",
|
|
||||||
"hostsDesc": "Добавить Hosts",
|
"hostsDesc": "Добавить Hosts",
|
||||||
"vpnTip": "Изменения вступят в силу после перезапуска VPN",
|
"vpnTip": "Изменения вступят в силу после перезапуска VPN",
|
||||||
"vpnEnableDesc": "Автоматически направляет весь системный трафик через VpnService",
|
"vpnEnableDesc": "Автоматически направляет весь системный трафик через VpnService",
|
||||||
@@ -337,15 +329,12 @@
|
|||||||
"fileIsUpdate": "Файл был изменен. Хотите сохранить изменения?",
|
"fileIsUpdate": "Файл был изменен. Хотите сохранить изменения?",
|
||||||
"profileHasUpdate": "Профиль был изменен. Хотите отключить автообновление?",
|
"profileHasUpdate": "Профиль был изменен. Хотите отключить автообновление?",
|
||||||
"hasCacheChange": "Хотите сохранить изменения в кэше?",
|
"hasCacheChange": "Хотите сохранить изменения в кэше?",
|
||||||
"nullProxies": "Нет прокси",
|
|
||||||
"copySuccess": "Копирование успешно",
|
"copySuccess": "Копирование успешно",
|
||||||
"copyLink": "Копировать ссылку",
|
"copyLink": "Копировать ссылку",
|
||||||
"exportFile": "Экспорт файла",
|
"exportFile": "Экспорт файла",
|
||||||
"cacheCorrupt": "Кэш поврежден. Хотите очистить его?",
|
"cacheCorrupt": "Кэш поврежден. Хотите очистить его?",
|
||||||
"detectionTip": "Опирается на сторонний API, только для справки",
|
"detectionTip": "Опирается на сторонний API, только для справки",
|
||||||
"listen": "Слушать",
|
"listen": "Слушать",
|
||||||
"keyExists": "Текущий ключ уже существует",
|
|
||||||
"valueExists": "Текущее значение уже существует",
|
|
||||||
"undo": "Отменить",
|
"undo": "Отменить",
|
||||||
"redo": "Повторить",
|
"redo": "Повторить",
|
||||||
"none": "Нет",
|
"none": "Нет",
|
||||||
@@ -353,28 +342,21 @@
|
|||||||
"basicConfigDesc": "Глобальное изменение базовых настроек",
|
"basicConfigDesc": "Глобальное изменение базовых настроек",
|
||||||
"selectedCountTitle": "Выбрано {count} элементов",
|
"selectedCountTitle": "Выбрано {count} элементов",
|
||||||
"addRule": "Добавить правило",
|
"addRule": "Добавить правило",
|
||||||
"ruleProviderEmptyTip": "Поставщик правил не может быть пустым",
|
|
||||||
"ruleName": "Название правила",
|
"ruleName": "Название правила",
|
||||||
"content": "Содержание",
|
"content": "Содержание",
|
||||||
"contentEmptyTip": "Содержание не может быть пустым",
|
|
||||||
"subRule": "Подправило",
|
"subRule": "Подправило",
|
||||||
"subRuleEmptyTip": "Содержание подправила не может быть пустым",
|
|
||||||
"ruleTarget": "Цель правила",
|
"ruleTarget": "Цель правила",
|
||||||
"ruleTargetEmptyTip": "Цель правила не может быть пустой",
|
|
||||||
"sourceIp": "Исходный IP",
|
"sourceIp": "Исходный IP",
|
||||||
"noResolve": "Не разрешать IP",
|
"noResolve": "Не разрешать IP",
|
||||||
"getOriginRules": "Получить оригинальные правила",
|
"getOriginRules": "Получить оригинальные правила",
|
||||||
"overrideOriginRules": "Переопределить оригинальное правило",
|
"overrideOriginRules": "Переопределить оригинальное правило",
|
||||||
"addedOriginRules": "Добавить к оригинальным правилам",
|
"addedOriginRules": "Добавить к оригинальным правилам",
|
||||||
"enableOverride": "Включить переопределение",
|
"enableOverride": "Включить переопределение",
|
||||||
"deleteRuleTip": "Вы уверены, что хотите удалить выбранное правило?",
|
|
||||||
"saveChanges": "Сохранить изменения?",
|
"saveChanges": "Сохранить изменения?",
|
||||||
"generalDesc": "Изменение общих настроек",
|
"generalDesc": "Изменение общих настроек",
|
||||||
"findProcessModeDesc": "При включении возможны небольшие потери производительности",
|
"findProcessModeDesc": "При включении возможны небольшие потери производительности",
|
||||||
"tabAnimationDesc": "Действительно только в мобильном виде",
|
"tabAnimationDesc": "Действительно только в мобильном виде",
|
||||||
"saveTip": "Вы уверены, что хотите сохранить?",
|
"saveTip": "Вы уверены, что хотите сохранить?",
|
||||||
"deleteColorTip": "Удалить текущий цвет?",
|
|
||||||
"colorExists": "Этот цвет уже существует",
|
|
||||||
"colorSchemes": "Цветовые схемы",
|
"colorSchemes": "Цветовые схемы",
|
||||||
"palette": "Палитра",
|
"palette": "Палитра",
|
||||||
"tonalSpotScheme": "Тональный акцент",
|
"tonalSpotScheme": "Тональный акцент",
|
||||||
@@ -401,5 +383,55 @@
|
|||||||
"recoveryStrategy": "Стратегия восстановления",
|
"recoveryStrategy": "Стратегия восстановления",
|
||||||
"recoveryStrategy_override": "Переопределение",
|
"recoveryStrategy_override": "Переопределение",
|
||||||
"recoveryStrategy_compatible": "Совместимый",
|
"recoveryStrategy_compatible": "Совместимый",
|
||||||
"logsTest": "Тест журналов"
|
"logsTest": "Тест журналов",
|
||||||
|
"emptyTip": "{label} не может быть пустым",
|
||||||
|
"urlTip": "{label} должен быть URL",
|
||||||
|
"numberTip": "{label} должно быть числом",
|
||||||
|
"interval": "Интервал",
|
||||||
|
"existsTip": "Текущий {label} уже существует",
|
||||||
|
"deleteTip": "Вы уверены, что хотите удалить текущий {label}?",
|
||||||
|
"deleteMultipTip": "Вы уверены, что хотите удалить выбранные {label}?",
|
||||||
|
"nullTip": "Сейчас {label} нет",
|
||||||
|
"script": "Скрипт",
|
||||||
|
"color": "Цвет",
|
||||||
|
"rename": "Переименовать",
|
||||||
|
"unnamed": "Без имени",
|
||||||
|
"pleaseEnterScriptName": "Пожалуйста, введите название скрипта",
|
||||||
|
"overrideInvalidTip": "В скриптовом режиме не действует",
|
||||||
|
"mixedPort": "Смешанный порт",
|
||||||
|
"socksPort": "Socks-порт",
|
||||||
|
"redirPort": "Redir-порт",
|
||||||
|
"tproxyPort": "Tproxy-порт",
|
||||||
|
"portTip": "{label} должен быть числом от 1024 до 49151",
|
||||||
|
"portConflictTip": "Введите другой порт",
|
||||||
|
"import": "Импорт",
|
||||||
|
"importFile": "Импорт из файла",
|
||||||
|
"importUrl": "Импорт по URL",
|
||||||
|
"autoSetSystemDns": "Автоматическая настройка системного DNS",
|
||||||
|
"details": "Детали {}",
|
||||||
|
"creationTime": "Время создания",
|
||||||
|
"process": "процесс",
|
||||||
|
"host": "Хост",
|
||||||
|
"destination": "Назначение",
|
||||||
|
"destinationGeoIP": "Геолокация назначения",
|
||||||
|
"destinationIPASN": "ASN назначения",
|
||||||
|
"specialProxy": "Специальный прокси",
|
||||||
|
"specialRules": "Специальные правила",
|
||||||
|
"remoteDestination": "Удалённое назначение",
|
||||||
|
"networkType": "Тип сети",
|
||||||
|
"proxyChains": "Цепочки прокси",
|
||||||
|
"log": "Журнал",
|
||||||
|
"connection": "Соединение",
|
||||||
|
"request": "Запрос",
|
||||||
|
"connected": "Подключено",
|
||||||
|
"disconnected": "Отключено",
|
||||||
|
"connecting": "Подключение...",
|
||||||
|
"restartCoreTip": "Вы уверены, что хотите перезапустить ядро?",
|
||||||
|
"forceRestartCoreTip": "Вы уверены, что хотите принудительно перезапустить ядро?",
|
||||||
|
"dnsHijacking": "DNS-перехват",
|
||||||
|
"coreStatus": "Основной статус",
|
||||||
|
"dataCollectionTip": "Уведомление о сборе данных",
|
||||||
|
"dataCollectionContent": "Это приложение использует Firebase Crashlytics для сбора информации о сбоях nhằm улучшения стабильности приложения.\nСобираемые данные включают информацию об устройстве и подробности о сбоях, но не содержат персональных конфиденциальных данных.\nВы можете отключить эту функцию в настройках.",
|
||||||
|
"crashlytics": "Анализ сбоев",
|
||||||
|
"crashlyticsTip": "При включении автоматически загружает журналы сбоев без конфиденциальной информации, когда приложение выходит из строя"
|
||||||
}
|
}
|
||||||
@@ -13,7 +13,6 @@
|
|||||||
"resourcesDesc": "外部资源相关信息",
|
"resourcesDesc": "外部资源相关信息",
|
||||||
"trafficUsage": "流量统计",
|
"trafficUsage": "流量统计",
|
||||||
"coreInfo": "内核信息",
|
"coreInfo": "内核信息",
|
||||||
"nullCoreInfoDesc": "无法获取内核信息",
|
|
||||||
"networkSpeed": "网络速度",
|
"networkSpeed": "网络速度",
|
||||||
"outboundMode": "出站模式",
|
"outboundMode": "出站模式",
|
||||||
"networkDetection": "网络检测",
|
"networkDetection": "网络检测",
|
||||||
@@ -22,7 +21,6 @@
|
|||||||
"noProxy": "暂无代理",
|
"noProxy": "暂无代理",
|
||||||
"noProxyDesc": "请创建配置文件或者添加有效配置文件",
|
"noProxyDesc": "请创建配置文件或者添加有效配置文件",
|
||||||
"nullProfileDesc": "没有配置文件,请先添加配置文件",
|
"nullProfileDesc": "没有配置文件,请先添加配置文件",
|
||||||
"nullLogsDesc": "暂无日志",
|
|
||||||
"settings": "设置",
|
"settings": "设置",
|
||||||
"language": "语言",
|
"language": "语言",
|
||||||
"defaultText": "默认",
|
"defaultText": "默认",
|
||||||
@@ -149,8 +147,6 @@
|
|||||||
"addressHelp": "WebDAV服务器地址",
|
"addressHelp": "WebDAV服务器地址",
|
||||||
"addressTip": "请输入有效的WebDAV地址",
|
"addressTip": "请输入有效的WebDAV地址",
|
||||||
"password": "密码",
|
"password": "密码",
|
||||||
"passwordTip": "密码不能为空",
|
|
||||||
"accountTip": "账号不能为空",
|
|
||||||
"checkUpdate": "检查更新",
|
"checkUpdate": "检查更新",
|
||||||
"discoverNewVersion": "发现新版本",
|
"discoverNewVersion": "发现新版本",
|
||||||
"checkUpdateError": "当前应用已经是最新版了",
|
"checkUpdateError": "当前应用已经是最新版了",
|
||||||
@@ -185,8 +181,6 @@
|
|||||||
"expirationTime": "到期时间",
|
"expirationTime": "到期时间",
|
||||||
"connections": "连接",
|
"connections": "连接",
|
||||||
"connectionsDesc": "查看当前连接数据",
|
"connectionsDesc": "查看当前连接数据",
|
||||||
"nullRequestsDesc": "暂无请求",
|
|
||||||
"nullConnectionsDesc": "暂无连接",
|
|
||||||
"intranetIP": "内网 IP",
|
"intranetIP": "内网 IP",
|
||||||
"view": "查看",
|
"view": "查看",
|
||||||
"cut": "剪切",
|
"cut": "剪切",
|
||||||
@@ -219,7 +213,6 @@
|
|||||||
"autoCloseConnectionsDesc": "切换节点后自动关闭连接",
|
"autoCloseConnectionsDesc": "切换节点后自动关闭连接",
|
||||||
"onlyStatisticsProxy": "仅统计代理",
|
"onlyStatisticsProxy": "仅统计代理",
|
||||||
"onlyStatisticsProxyDesc": "开启后,将只统计代理流量",
|
"onlyStatisticsProxyDesc": "开启后,将只统计代理流量",
|
||||||
"deleteProfileTip": "确定要删除当前配置吗?",
|
|
||||||
"pureBlackMode": "纯黑模式",
|
"pureBlackMode": "纯黑模式",
|
||||||
"keepAliveIntervalDesc": "TCP保持活动间隔",
|
"keepAliveIntervalDesc": "TCP保持活动间隔",
|
||||||
"entries": "个条目",
|
"entries": "个条目",
|
||||||
@@ -250,7 +243,6 @@
|
|||||||
"dnsDesc": "更新DNS相关设置",
|
"dnsDesc": "更新DNS相关设置",
|
||||||
"key": "键",
|
"key": "键",
|
||||||
"value": "值",
|
"value": "值",
|
||||||
"notEmpty": "不能为空",
|
|
||||||
"hostsDesc": "追加Hosts",
|
"hostsDesc": "追加Hosts",
|
||||||
"vpnTip": "重启VPN后改变生效",
|
"vpnTip": "重启VPN后改变生效",
|
||||||
"vpnEnableDesc": "通过VpnService自动路由系统所有流量",
|
"vpnEnableDesc": "通过VpnService自动路由系统所有流量",
|
||||||
@@ -337,15 +329,12 @@
|
|||||||
"fileIsUpdate": "文件有修改,是否保存修改",
|
"fileIsUpdate": "文件有修改,是否保存修改",
|
||||||
"profileHasUpdate": "配置文件已经修改,是否关闭自动更新 ",
|
"profileHasUpdate": "配置文件已经修改,是否关闭自动更新 ",
|
||||||
"hasCacheChange": "是否缓存修改",
|
"hasCacheChange": "是否缓存修改",
|
||||||
"nullProxies": "暂无代理",
|
|
||||||
"copySuccess": "复制成功",
|
"copySuccess": "复制成功",
|
||||||
"copyLink": "复制链接",
|
"copyLink": "复制链接",
|
||||||
"exportFile": "导出文件",
|
"exportFile": "导出文件",
|
||||||
"cacheCorrupt": "缓存已损坏,是否清空?",
|
"cacheCorrupt": "缓存已损坏,是否清空?",
|
||||||
"detectionTip": "依赖第三方api,仅供参考",
|
"detectionTip": "依赖第三方api,仅供参考",
|
||||||
"listen": "监听",
|
"listen": "监听",
|
||||||
"keyExists": "当前键已存在",
|
|
||||||
"valueExists": "当前值已存在",
|
|
||||||
"undo": "撤销",
|
"undo": "撤销",
|
||||||
"redo": "重做",
|
"redo": "重做",
|
||||||
"none": "无",
|
"none": "无",
|
||||||
@@ -353,28 +342,21 @@
|
|||||||
"basicConfigDesc": "全局修改基本配置",
|
"basicConfigDesc": "全局修改基本配置",
|
||||||
"selectedCountTitle": "已选择 {count} 项",
|
"selectedCountTitle": "已选择 {count} 项",
|
||||||
"addRule": "添加规则",
|
"addRule": "添加规则",
|
||||||
"ruleProviderEmptyTip": "规则提供者不能为空",
|
|
||||||
"ruleName": "规则名称",
|
"ruleName": "规则名称",
|
||||||
"content": "内容",
|
"content": "内容",
|
||||||
"contentEmptyTip": "内容不能为空",
|
|
||||||
"subRule": "子规则",
|
"subRule": "子规则",
|
||||||
"subRuleEmptyTip": "子规则内容不能为空",
|
|
||||||
"ruleTarget": "规则目标",
|
"ruleTarget": "规则目标",
|
||||||
"ruleTargetEmptyTip": "规则目标不能为空",
|
|
||||||
"sourceIp": "源IP",
|
"sourceIp": "源IP",
|
||||||
"noResolve": "不解析IP",
|
"noResolve": "不解析IP",
|
||||||
"getOriginRules": "获取原始规则",
|
"getOriginRules": "获取原始规则",
|
||||||
"overrideOriginRules": "覆盖原始规则",
|
"overrideOriginRules": "覆盖原始规则",
|
||||||
"addedOriginRules": "附加到原始规则",
|
"addedOriginRules": "附加到原始规则",
|
||||||
"enableOverride": "启用覆写",
|
"enableOverride": "启用覆写",
|
||||||
"deleteRuleTip": "确定要删除选中的规则吗?",
|
|
||||||
"saveChanges": "是否保存更改?",
|
"saveChanges": "是否保存更改?",
|
||||||
"generalDesc": "修改通用设置",
|
"generalDesc": "修改通用设置",
|
||||||
"findProcessModeDesc": "开启后会有一定性能损耗",
|
"findProcessModeDesc": "开启后会有一定性能损耗",
|
||||||
"tabAnimationDesc": "仅在移动视图中有效",
|
"tabAnimationDesc": "仅在移动视图中有效",
|
||||||
"saveTip": "确定要保存吗?",
|
"saveTip": "确定要保存吗?",
|
||||||
"deleteColorTip": "确定删除当前颜色吗?",
|
|
||||||
"colorExists": "该颜色已存在",
|
|
||||||
"colorSchemes": "配色方案",
|
"colorSchemes": "配色方案",
|
||||||
"palette": "调色板",
|
"palette": "调色板",
|
||||||
"tonalSpotScheme": "调性点缀",
|
"tonalSpotScheme": "调性点缀",
|
||||||
@@ -401,5 +383,55 @@
|
|||||||
"recoveryStrategy": "恢复策略",
|
"recoveryStrategy": "恢复策略",
|
||||||
"recoveryStrategy_override": "覆盖",
|
"recoveryStrategy_override": "覆盖",
|
||||||
"recoveryStrategy_compatible": "兼容",
|
"recoveryStrategy_compatible": "兼容",
|
||||||
"logsTest": "日志测试"
|
"logsTest": "日志测试",
|
||||||
|
"emptyTip": "{label}不能为空",
|
||||||
|
"urlTip": "{label}必须为URL",
|
||||||
|
"numberTip": "{label}必须为数字",
|
||||||
|
"interval": "间隔",
|
||||||
|
"existsTip": "{label}当前已存在",
|
||||||
|
"deleteTip": "确定删除当前{label}吗?",
|
||||||
|
"deleteMultipTip": "确定删除选中的{label}吗?",
|
||||||
|
"nullTip": "暂无{label}",
|
||||||
|
"script": "脚本",
|
||||||
|
"color": "颜色",
|
||||||
|
"rename": "重命名",
|
||||||
|
"unnamed": "未命名",
|
||||||
|
"pleaseEnterScriptName": "请输入脚本名称",
|
||||||
|
"overrideInvalidTip": "在脚本模式下不生效",
|
||||||
|
"mixedPort": "混合端口",
|
||||||
|
"socksPort": "Socks端口",
|
||||||
|
"redirPort": "Redir端口",
|
||||||
|
"tproxyPort": "Tproxy端口",
|
||||||
|
"portTip": "{label} 必须在 1024 到 49151 之间",
|
||||||
|
"portConflictTip": "请输入不同的端口",
|
||||||
|
"import": "导入",
|
||||||
|
"importFile": "通过文件导入",
|
||||||
|
"importUrl": "通过URL导入",
|
||||||
|
"autoSetSystemDns": "自动设置系统DNS",
|
||||||
|
"details": "{label}详情",
|
||||||
|
"creationTime": "创建时间",
|
||||||
|
"process": "进程",
|
||||||
|
"host": "主机",
|
||||||
|
"destination": "目标地址",
|
||||||
|
"destinationGeoIP": "目标地理定位",
|
||||||
|
"destinationIPASN": "目标IP ASN",
|
||||||
|
"specialProxy": "特殊代理",
|
||||||
|
"specialRules": "特殊规则",
|
||||||
|
"remoteDestination": "远程目标",
|
||||||
|
"networkType": "网络类型",
|
||||||
|
"proxyChains": "代理链",
|
||||||
|
"log": "日志",
|
||||||
|
"connection": "连接",
|
||||||
|
"request": "请求",
|
||||||
|
"connected": "已连接",
|
||||||
|
"disconnected": "已断开",
|
||||||
|
"connecting": "连接中...",
|
||||||
|
"restartCoreTip": "您确定要重启核心吗?",
|
||||||
|
"forceRestartCoreTip": "您确定要强制重启核心吗?",
|
||||||
|
"dnsHijacking": "DNS劫持",
|
||||||
|
"coreStatus": "核心状态",
|
||||||
|
"dataCollectionTip": "数据收集说明",
|
||||||
|
"dataCollectionContent": "本应用使用 Firebase Crashlytics 收集崩溃信息以改进应用稳定性。\n收集的数据包括设备信息和崩溃详情,不包含个人敏感数据。\n您可以在设置中关闭此功能。",
|
||||||
|
"crashlytics": "崩溃分析",
|
||||||
|
"crashlyticsTip": "开启后,应用崩溃时自动上传不包含敏感信息的崩溃日志"
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user