mirror of
https://github.com/MatsuriDayo/NekoBoxForAndroid.git
synced 2025-12-18 22:20:06 +08:00
upload code
This commit is contained in:
commit
7d9798cc27
22
.github/ISSUE_TEMPLATE/bug-report-zh_cn.md
vendored
Normal file
22
.github/ISSUE_TEMPLATE/bug-report-zh_cn.md
vendored
Normal file
@ -0,0 +1,22 @@
|
||||
---
|
||||
name: Bug Report zh_CN
|
||||
about: 问题反馈,在提出问题前请先自行排除服务器端问题和升级到最新客户端。
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**描述问题**
|
||||
|
||||
预期行为:
|
||||
|
||||
实际行为:
|
||||
|
||||
**如何复现**
|
||||
|
||||
提供有帮助的截图,录像,文字说明,订阅链接等。
|
||||
|
||||
**日志**
|
||||
|
||||
如果有日志,请上传。请在文档内查看导出日志的详细步骤。
|
||||
12
.github/ISSUE_TEMPLATE/feature_request-zh_cn.md
vendored
Normal file
12
.github/ISSUE_TEMPLATE/feature_request-zh_cn.md
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
---
|
||||
name: Feature Request zh_CN
|
||||
about: 功能请求,提出建议。
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**描述建议**
|
||||
|
||||
**建议的必要性**
|
||||
171
.github/workflows/release.yml
vendored
Normal file
171
.github/workflows/release.yml
vendored
Normal file
@ -0,0 +1,171 @@
|
||||
name: Release Build
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release Tag'
|
||||
required: true
|
||||
upload:
|
||||
description: 'Upload: If want ignore'
|
||||
required: false
|
||||
publish:
|
||||
description: 'Publish: If want ignore'
|
||||
required: false
|
||||
play:
|
||||
description: 'Play: If want ignore'
|
||||
required: false
|
||||
jobs:
|
||||
libcore:
|
||||
name: Native Build (LibCore)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: Golang Status
|
||||
run: find buildScript libcore/*.sh | xargs cat | sha1sum > golang_status
|
||||
- name: Libcore Status
|
||||
run: git ls-files libcore | xargs cat | sha1sum > libcore_status
|
||||
- name: LibCore Cache
|
||||
id: cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
app/libs/libcore.aar
|
||||
key: ${{ hashFiles('.github/workflows/*', 'golang_status', 'libcore_status') }}
|
||||
- name: Golang Cache
|
||||
if: steps.cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: build/golang
|
||||
key: go-${{ hashFiles('.github/workflows/*', 'golang_status') }}
|
||||
- name: Native Build
|
||||
if: steps.cache.outputs.cache-hit != 'true'
|
||||
run: ./run init action go && ./run lib core
|
||||
build:
|
||||
name: Build OSS APK
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- libcore
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: Golang Status
|
||||
run: find buildScript libcore/*.sh | xargs cat | sha1sum > golang_status
|
||||
- name: Libcore Status
|
||||
run: git ls-files libcore | xargs cat | sha1sum > libcore_status
|
||||
- name: LibCore Cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
app/libs/libcore.aar
|
||||
key: ${{ hashFiles('.github/workflows/*', 'golang_status', 'libcore_status') }}
|
||||
- name: Gradle cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.gradle
|
||||
key: gradle-oss-${{ hashFiles('**/*.gradle.kts') }}
|
||||
- name: Gradle Build
|
||||
env:
|
||||
BUILD_PLUGIN: none
|
||||
run: |
|
||||
echo "sdk.dir=${ANDROID_HOME}" > local.properties
|
||||
echo "ndk.dir=${ANDROID_HOME}/ndk/25.0.8775105" >> local.properties
|
||||
export LOCAL_PROPERTIES="${{ secrets.LOCAL_PROPERTIES }}"
|
||||
./run init action gradle
|
||||
./gradlew app:assembleOssRelease
|
||||
APK=$(find app/build/outputs/apk -name '*arm64-v8a*.apk')
|
||||
APK=$(dirname $APK)
|
||||
echo "APK=$APK" >> $GITHUB_ENV
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: APKs
|
||||
path: ${{ env.APK }}
|
||||
publish:
|
||||
name: Publish Release
|
||||
if: github.event.inputs.publish != 'y'
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: Donwload Artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: APKs
|
||||
path: artifacts
|
||||
- name: Release
|
||||
run: |
|
||||
wget -O ghr.tar.gz https://github.com/tcnksm/ghr/releases/download/v0.13.0/ghr_v0.13.0_linux_amd64.tar.gz
|
||||
tar -xvf ghr.tar.gz
|
||||
mv ghr*linux_amd64/ghr .
|
||||
mkdir apks
|
||||
find artifacts -name "*.apk" -exec cp {} apks \;
|
||||
./ghr -delete -t "${{ github.token }}" -n "${{ github.event.inputs.tag }}" "${{ github.event.inputs.tag }}" apks
|
||||
upload:
|
||||
name: Upload Release
|
||||
if: github.event.inputs.upload != 'y'
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
steps:
|
||||
- name: Donwload Artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: APKs
|
||||
path: artifacts
|
||||
- name: Release
|
||||
run: |
|
||||
mkdir apks
|
||||
find artifacts -name "*.apk" -exec cp {} apks \;
|
||||
|
||||
function upload() {
|
||||
for apk in $@; do
|
||||
echo ">> Uploading $apk"
|
||||
curl https://api.telegram.org/bot${{ secrets.TELEGRAM_TOKEN }}/sendDocument \
|
||||
-X POST \
|
||||
-F chat_id="${{ secrets.TELEGRAM_CHANNEL }}" \
|
||||
-F document="@$apk" \
|
||||
--silent --show-error --fail >/dev/null &
|
||||
done
|
||||
for job in $(jobs -p); do
|
||||
wait $job || exit 1
|
||||
done
|
||||
}
|
||||
upload apks/*
|
||||
play:
|
||||
name: Build Play Bundle
|
||||
if: github.event.inputs.play != 'y'
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- libcore
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: Golang Status
|
||||
run: find buildScript libcore/*.sh | xargs cat | sha1sum > golang_status
|
||||
- name: Libcore Status
|
||||
run: git ls-files libcore | xargs cat | sha1sum > libcore_status
|
||||
- name: LibCore Cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
app/libs/libcore.aar
|
||||
key: ${{ hashFiles('.github/workflows/*', 'golang_status', 'libcore_status') }}
|
||||
- name: Gradle cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.gradle
|
||||
key: gradle-play-${{ hashFiles('**/*.gradle.kts') }}
|
||||
- name: Checkout Library
|
||||
run: |
|
||||
git submodule update --init 'app/*'
|
||||
- name: Gradle Build
|
||||
run: |
|
||||
echo "sdk.dir=${ANDROID_HOME}" > local.properties
|
||||
echo "ndk.dir=${ANDROID_HOME}/ndk/25.0.8775105" >> local.properties
|
||||
export LOCAL_PROPERTIES="${{ secrets.LOCAL_PROPERTIES }}"
|
||||
./run init action gradle
|
||||
./gradlew bundlePlayRelease
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: AAB
|
||||
path: app/build/outputs/bundle/playRelease/app-play-release.aab
|
||||
20
.gitignore
vendored
Normal file
20
.gitignore
vendored
Normal file
@ -0,0 +1,20 @@
|
||||
*.iml
|
||||
.gradle
|
||||
.idea
|
||||
.vscode
|
||||
.DS_Store
|
||||
build/
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
/app/libs/
|
||||
/app/src/main/assets/sing-box
|
||||
/service_account_credentials.json
|
||||
jniLibs/
|
||||
/library/libcore_build/
|
||||
.idea/deploymentTargetDropDown.xml
|
||||
/nkmr
|
||||
|
||||
# submodules
|
||||
/external
|
||||
8
AUTHORS
Normal file
8
AUTHORS
Normal file
@ -0,0 +1,8 @@
|
||||
SagerNet was originally created in late 2021, by
|
||||
nekohasekai <contact-sagernet@sekai.icu>.
|
||||
|
||||
Here is an inevitably incomplete list of MUCH-APPRECIATED CONTRIBUTORS --
|
||||
people who have submitted patches, fixed bugs, added translations, and
|
||||
generally made SagerNet that much better:
|
||||
|
||||
https://github.com/SagerNet/SagerNet/graphs/contributors
|
||||
14
LICENSE
Normal file
14
LICENSE
Normal file
@ -0,0 +1,14 @@
|
||||
Copyright (C) 2021 by nekohasekai <contact-sagernet@sekai.icu>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
60
README.md
Normal file
60
README.md
Normal file
@ -0,0 +1,60 @@
|
||||
# NekoBox for Android
|
||||
|
||||
[](https://android-arsenal.com/api?level=21)
|
||||
[](https://github.com/MatsuriDayo/NekoBoxForAndroid/releases)
|
||||
[](https://www.gnu.org/licenses/gpl-3.0)
|
||||
|
||||
sing-box / universal proxy toolchain for Android.
|
||||
|
||||
## 下载 / Downloads
|
||||
|
||||
### GitHub Releases
|
||||
|
||||
[](https://github.com/Matsuridayo/NekoBoxForAndroid/releases)
|
||||
|
||||
[下载](https://github.com/Matsuridayo/NekoBoxForAndroid/releases)
|
||||
|
||||
## 更改记录 & 发布频道 / Changelog & Telegram channel
|
||||
|
||||
https://t.me/Matsuridayo
|
||||
|
||||
## 项目主页 & 文档 / Homepage & Documents
|
||||
|
||||
https://matsuridayo.github.io
|
||||
|
||||
## 代理 / Proxy
|
||||
|
||||
* SOCKS (4/4a/5)
|
||||
* HTTP(S)
|
||||
* SSH
|
||||
* Shadowsocks
|
||||
* VMess
|
||||
* VLESS
|
||||
* WireGuard
|
||||
* Trojan
|
||||
* Trojan-Go ( trojan-go-plugin )
|
||||
* NaïveProxy ( naive-plugin )
|
||||
* Hysteria ( hysteria-plugin )
|
||||
|
||||
请到项目主页下载插件。
|
||||
|
||||
Please go to the project homepage to download plugins.
|
||||
|
||||
### 订阅 / Subscription
|
||||
|
||||
* Raw: some widely used formats (like shadowsocks, clash and v2rayN)
|
||||
* 原始格式:一些广泛使用的格式(如 shadowsocks、clash 和 v2rayN)
|
||||
* [Open Online Config](https://github.com/Shadowsocks-NET/OpenOnlineConfig)
|
||||
* [Shadowsocks SIP008](https://shadowsocks.org/guide/sip008.html)
|
||||
|
||||
### 捐助 / Donate
|
||||
|
||||
欢迎捐赠以支持项目开发。
|
||||
|
||||
USDT TRC20
|
||||
|
||||
`TRhnA7SXE5Sap5gSG3ijxRmdYFiD4KRhPs`
|
||||
|
||||
XMR
|
||||
|
||||
`49bwESYQjoRL3xmvTcjZKHEKaiGywjLYVQJMUv79bXonGiyDCs8AzE3KiGW2ytTybBCpWJUvov8SjZZEGg66a4e59GXa6k5`
|
||||
2
app/.gitignore
vendored
Normal file
2
app/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
/build
|
||||
/schemas
|
||||
81
app/build.gradle.kts
Normal file
81
app/build.gradle.kts
Normal file
@ -0,0 +1,81 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
id("kotlin-kapt")
|
||||
id("kotlin-parcelize")
|
||||
}
|
||||
|
||||
setupApp()
|
||||
|
||||
android {
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
}
|
||||
kapt.arguments {
|
||||
arg("room.incremental", true)
|
||||
arg("room.schemaLocation", "$projectDir/schemas")
|
||||
}
|
||||
bundle {
|
||||
language {
|
||||
enableSplit = false
|
||||
}
|
||||
}
|
||||
buildFeatures {
|
||||
viewBinding = true
|
||||
}
|
||||
namespace = "io.nekohasekai.sagernet"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
implementation(fileTree("libs"))
|
||||
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.3")
|
||||
implementation("androidx.core:core-ktx:1.7.0")
|
||||
implementation("androidx.recyclerview:recyclerview:1.2.1")
|
||||
implementation("androidx.activity:activity-ktx:1.4.0")
|
||||
implementation("androidx.fragment:fragment-ktx:1.4.1")
|
||||
implementation("androidx.browser:browser:1.4.0")
|
||||
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
|
||||
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
|
||||
implementation("androidx.navigation:navigation-fragment-ktx:2.4.2")
|
||||
implementation("androidx.navigation:navigation-ui-ktx:2.4.2")
|
||||
implementation("androidx.preference:preference-ktx:1.2.0")
|
||||
implementation("androidx.appcompat:appcompat:1.4.1")
|
||||
implementation("androidx.work:work-runtime-ktx:2.7.1")
|
||||
implementation("androidx.work:work-multiprocess:2.7.1")
|
||||
|
||||
implementation(project(":external:preferencex:preferencex"))
|
||||
implementation(project(":external:preferencex:preferencex-simplemenu"))
|
||||
|
||||
implementation("com.google.android.material:material:1.6.0")
|
||||
implementation("com.google.code.gson:gson:2.8.9")
|
||||
|
||||
implementation("com.github.jenly1314:zxing-lite:2.1.1")
|
||||
implementation("com.afollestad.material-dialogs:core:3.3.0")
|
||||
implementation("com.afollestad.material-dialogs:input:3.3.0")
|
||||
|
||||
implementation("com.squareup.okhttp3:okhttp:5.0.0-alpha.3")
|
||||
implementation("org.yaml:snakeyaml:1.30")
|
||||
implementation("com.github.daniel-stoneuk:material-about-library:3.2.0-rc01")
|
||||
implementation("com.jakewharton:process-phoenix:2.1.2")
|
||||
implementation("com.esotericsoftware:kryo:5.2.1")
|
||||
implementation("com.google.guava:guava:31.0.1-android")
|
||||
implementation("org.ini4j:ini4j:0.5.4")
|
||||
|
||||
implementation("com.simplecityapps:recyclerview-fastscroll:2.0.1") {
|
||||
exclude(group = "androidx.recyclerview")
|
||||
exclude(group = "androidx.appcompat")
|
||||
}
|
||||
implementation("org.smali:dexlib2:2.5.2") {
|
||||
exclude(group = "com.google.guava", module = "guava")
|
||||
}
|
||||
|
||||
implementation("androidx.room:room-runtime:2.4.2")
|
||||
kapt("androidx.room:room-compiler:2.4.2")
|
||||
implementation("androidx.room:room-ktx:2.4.2")
|
||||
implementation("com.github.MatrixDev.Roomigrant:RoomigrantLib:0.3.4")
|
||||
kapt("com.github.MatrixDev.Roomigrant:RoomigrantCompiler:0.3.4")
|
||||
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:1.1.5")
|
||||
}
|
||||
47
app/proguard-rules.pro
vendored
Normal file
47
app/proguard-rules.pro
vendored
Normal file
@ -0,0 +1,47 @@
|
||||
-repackageclasses ''
|
||||
-allowaccessmodification
|
||||
|
||||
-keep class io.nekohasekai.sagernet.** { *;}
|
||||
-keep class moe.matsuri.nb4a.** { *;}
|
||||
|
||||
# Clean Kotlin
|
||||
-assumenosideeffects class kotlin.jvm.internal.Intrinsics {
|
||||
static void checkParameterIsNotNull(java.lang.Object, java.lang.String);
|
||||
static void checkExpressionValueIsNotNull(java.lang.Object, java.lang.String);
|
||||
static void checkNotNullExpressionValue(java.lang.Object, java.lang.String);
|
||||
static void checkReturnedValueIsNotNull(java.lang.Object, java.lang.String, java.lang.String);
|
||||
static void checkReturnedValueIsNotNull(java.lang.Object, java.lang.String);
|
||||
static void checkFieldIsNotNull(java.lang.Object, java.lang.String, java.lang.String);
|
||||
static void checkFieldIsNotNull(java.lang.Object, java.lang.String);
|
||||
static void checkNotNull(java.lang.Object);
|
||||
static void checkNotNull(java.lang.Object, java.lang.String);
|
||||
static void checkNotNullParameter(java.lang.Object, java.lang.String);
|
||||
static void throwUninitializedPropertyAccessException(java.lang.String);
|
||||
}
|
||||
|
||||
# ini4j
|
||||
-keep public class org.ini4j.spi.** { <init>(); }
|
||||
|
||||
# SnakeYaml
|
||||
-keep class org.yaml.snakeyaml.** { *; }
|
||||
|
||||
-dontobfuscate
|
||||
-keepattributes SourceFile
|
||||
|
||||
-dontwarn java.beans.BeanInfo
|
||||
-dontwarn java.beans.FeatureDescriptor
|
||||
-dontwarn java.beans.IntrospectionException
|
||||
-dontwarn java.beans.Introspector
|
||||
-dontwarn java.beans.PropertyDescriptor
|
||||
-dontwarn java.beans.Transient
|
||||
-dontwarn java.beans.VetoableChangeListener
|
||||
-dontwarn java.beans.VetoableChangeSupport
|
||||
-dontwarn org.apache.harmony.xnet.provider.jsse.SSLParametersImpl
|
||||
-dontwarn org.bouncycastle.jce.provider.BouncyCastleProvider
|
||||
-dontwarn org.bouncycastle.jsse.BCSSLParameters
|
||||
-dontwarn org.bouncycastle.jsse.BCSSLSocket
|
||||
-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider
|
||||
-dontwarn org.openjsse.javax.net.ssl.SSLParameters
|
||||
-dontwarn org.openjsse.javax.net.ssl.SSLSocket
|
||||
-dontwarn org.openjsse.net.ssl.OpenJSSE
|
||||
-dontwarn java.beans.PropertyVetoException
|
||||
@ -0,0 +1,330 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 1,
|
||||
"identityHash": "f66fd943df1d9e86d281a2e32c9fdd47",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "proxy_groups",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userOrder` INTEGER NOT NULL, `ungrouped` INTEGER NOT NULL, `name` TEXT, `type` INTEGER NOT NULL, `subscription` BLOB, `order` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "userOrder",
|
||||
"columnName": "userOrder",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "ungrouped",
|
||||
"columnName": "ungrouped",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "type",
|
||||
"columnName": "type",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "subscription",
|
||||
"columnName": "subscription",
|
||||
"affinity": "BLOB",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "order",
|
||||
"columnName": "order",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "proxy_entities",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `groupId` INTEGER NOT NULL, `type` INTEGER NOT NULL, `userOrder` INTEGER NOT NULL, `tx` INTEGER NOT NULL, `rx` INTEGER NOT NULL, `status` INTEGER NOT NULL, `ping` INTEGER NOT NULL, `uuid` TEXT NOT NULL, `error` TEXT, `socksBean` BLOB, `httpBean` BLOB, `ssBean` BLOB, `vmessBean` BLOB, `trojanBean` BLOB, `trojanGoBean` BLOB, `naiveBean` BLOB, `hysteriaBean` BLOB, `tuicBean` BLOB, `sshBean` BLOB, `wgBean` BLOB, `chainBean` BLOB, `nekoBean` BLOB, `configBean` BLOB)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "groupId",
|
||||
"columnName": "groupId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "type",
|
||||
"columnName": "type",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "userOrder",
|
||||
"columnName": "userOrder",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "tx",
|
||||
"columnName": "tx",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "rx",
|
||||
"columnName": "rx",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "status",
|
||||
"columnName": "status",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "ping",
|
||||
"columnName": "ping",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "uuid",
|
||||
"columnName": "uuid",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "error",
|
||||
"columnName": "error",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "socksBean",
|
||||
"columnName": "socksBean",
|
||||
"affinity": "BLOB",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "httpBean",
|
||||
"columnName": "httpBean",
|
||||
"affinity": "BLOB",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "ssBean",
|
||||
"columnName": "ssBean",
|
||||
"affinity": "BLOB",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "vmessBean",
|
||||
"columnName": "vmessBean",
|
||||
"affinity": "BLOB",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "trojanBean",
|
||||
"columnName": "trojanBean",
|
||||
"affinity": "BLOB",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "trojanGoBean",
|
||||
"columnName": "trojanGoBean",
|
||||
"affinity": "BLOB",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "naiveBean",
|
||||
"columnName": "naiveBean",
|
||||
"affinity": "BLOB",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "hysteriaBean",
|
||||
"columnName": "hysteriaBean",
|
||||
"affinity": "BLOB",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "tuicBean",
|
||||
"columnName": "tuicBean",
|
||||
"affinity": "BLOB",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "sshBean",
|
||||
"columnName": "sshBean",
|
||||
"affinity": "BLOB",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "wgBean",
|
||||
"columnName": "wgBean",
|
||||
"affinity": "BLOB",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "chainBean",
|
||||
"columnName": "chainBean",
|
||||
"affinity": "BLOB",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "nekoBean",
|
||||
"columnName": "nekoBean",
|
||||
"affinity": "BLOB",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "configBean",
|
||||
"columnName": "configBean",
|
||||
"affinity": "BLOB",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "groupId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"groupId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `groupId` ON `${TABLE_NAME}` (`groupId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "rules",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `userOrder` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `domains` TEXT NOT NULL, `ip` TEXT NOT NULL, `port` TEXT NOT NULL, `sourcePort` TEXT NOT NULL, `network` TEXT NOT NULL, `source` TEXT NOT NULL, `protocol` TEXT NOT NULL, `outbound` INTEGER NOT NULL, `packages` TEXT NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "userOrder",
|
||||
"columnName": "userOrder",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "enabled",
|
||||
"columnName": "enabled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "domains",
|
||||
"columnName": "domains",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "ip",
|
||||
"columnName": "ip",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "port",
|
||||
"columnName": "port",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sourcePort",
|
||||
"columnName": "sourcePort",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "network",
|
||||
"columnName": "network",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "source",
|
||||
"columnName": "source",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "protocol",
|
||||
"columnName": "protocol",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "outbound",
|
||||
"columnName": "outbound",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "packages",
|
||||
"columnName": "packages",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f66fd943df1d9e86d281a2e32c9fdd47')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 1,
|
||||
"identityHash": "f1aab1fb633378621635c344dbc8ac7b",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "KeyValuePair",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `valueType` INTEGER NOT NULL, `value` BLOB NOT NULL, PRIMARY KEY(`key`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "key",
|
||||
"columnName": "key",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "valueType",
|
||||
"columnName": "valueType",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "value",
|
||||
"columnName": "value",
|
||||
"affinity": "BLOB",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"key"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f1aab1fb633378621635c344dbc8ac7b')"
|
||||
]
|
||||
}
|
||||
}
|
||||
46
app/schemas/moe.matsuri.nb4a.TempDatabase/1.json
Normal file
46
app/schemas/moe.matsuri.nb4a.TempDatabase/1.json
Normal file
@ -0,0 +1,46 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 1,
|
||||
"identityHash": "f1aab1fb633378621635c344dbc8ac7b",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "KeyValuePair",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `valueType` INTEGER NOT NULL, `value` BLOB NOT NULL, PRIMARY KEY(`key`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "key",
|
||||
"columnName": "key",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "valueType",
|
||||
"columnName": "valueType",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "value",
|
||||
"columnName": "value",
|
||||
"affinity": "BLOB",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"key"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f1aab1fb633378621635c344dbc8ac7b')"
|
||||
]
|
||||
}
|
||||
}
|
||||
316
app/src/main/AndroidManifest.xml
Normal file
316
app/src/main/AndroidManifest.xml
Normal file
@ -0,0 +1,316 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:installLocation="internalOnly">
|
||||
|
||||
<uses-sdk tools:overrideLibrary="com.google.zxing.client.android" />
|
||||
|
||||
<permission
|
||||
android:name="${applicationId}.SERVICE"
|
||||
android:protectionLevel="signature" />
|
||||
|
||||
<uses-permission android:name="${applicationId}.SERVICE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<uses-permission
|
||||
android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||
tools:ignore="QueryAllPackagesPermission" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
|
||||
<uses-feature
|
||||
android:name="android.software.leanback"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.touchscreen"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera.autofocus"
|
||||
android:required="false" />
|
||||
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="com.github.shadowsocks.plugin.ACTION_NATIVE_PLUGIN" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="io.nekohasekai.sagernet.plugin.ACTION_NATIVE_PLUGIN" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:name="io.nekohasekai.sagernet.SagerNet"
|
||||
android:allowBackup="true"
|
||||
android:autoRevokePermissions="allowed"
|
||||
android:banner="@mipmap/ic_launcher"
|
||||
android:dataExtractionRules="@xml/backup_rules"
|
||||
android:extractNativeLibs="true"
|
||||
android:fullBackupContent="@xml/backup_descriptor"
|
||||
android:fullBackupOnly="true"
|
||||
android:hardwareAccelerated="true"
|
||||
android:hasFragileUserData="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:largeHeap="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:roundIcon="@mipmap/ic_launcher"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Start">
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
android:resource="@xml/shortcuts" />
|
||||
|
||||
<activity
|
||||
android:name="io.nekohasekai.sagernet.ui.BlankActivity"
|
||||
android:configChanges="uiMode" />
|
||||
|
||||
<activity
|
||||
android:name="io.nekohasekai.sagernet.ui.MainActivity"
|
||||
android:configChanges="uiMode"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:label="@string/subscription_import">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data
|
||||
android:host="subscription"
|
||||
android:scheme="sn" />
|
||||
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:label="@string/subscription_import">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data
|
||||
android:host="install-config"
|
||||
android:scheme="clash" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:label="@string/profile_import">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="sn" />
|
||||
<data android:scheme="ss" />
|
||||
<data android:scheme="ssr" />
|
||||
<data android:scheme="socks" />
|
||||
<data android:scheme="socks4" />
|
||||
<data android:scheme="socksa" />
|
||||
<data android:scheme="sock5" />
|
||||
<data android:scheme="vmess" />
|
||||
<data android:scheme="trojan" />
|
||||
<data android:scheme="trojan-go" />
|
||||
<data android:scheme="naive+https" />
|
||||
<data android:scheme="naive+quic" />
|
||||
<data android:scheme="hysteria" />
|
||||
|
||||
</intent-filter>
|
||||
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
android:resource="@xml/shortcuts" />
|
||||
</activity>
|
||||
<activity
|
||||
android:name="io.nekohasekai.sagernet.ui.VpnRequestActivity"
|
||||
android:excludeFromRecents="true"
|
||||
android:taskAffinity="" />
|
||||
<activity
|
||||
android:name="io.nekohasekai.sagernet.ui.profile.SocksSettingsActivity"
|
||||
android:configChanges="uiMode" />
|
||||
<activity
|
||||
android:name="io.nekohasekai.sagernet.ui.profile.HttpSettingsActivity"
|
||||
android:configChanges="uiMode" />
|
||||
<activity
|
||||
android:name="io.nekohasekai.sagernet.ui.profile.ShadowsocksSettingsActivity"
|
||||
android:configChanges="uiMode" />
|
||||
<activity
|
||||
android:name="io.nekohasekai.sagernet.ui.profile.ShadowsocksRSettingsActivity"
|
||||
android:configChanges="uiMode" />
|
||||
<activity
|
||||
android:name="io.nekohasekai.sagernet.ui.profile.VMessSettingsActivity"
|
||||
android:configChanges="uiMode" />
|
||||
<activity
|
||||
android:name="io.nekohasekai.sagernet.ui.profile.TrojanSettingsActivity"
|
||||
android:configChanges="uiMode" />
|
||||
<activity
|
||||
android:name="io.nekohasekai.sagernet.ui.profile.TrojanGoSettingsActivity"
|
||||
android:configChanges="uiMode" />
|
||||
<activity
|
||||
android:name="io.nekohasekai.sagernet.ui.profile.NaiveSettingsActivity"
|
||||
android:configChanges="uiMode" />
|
||||
<activity
|
||||
android:name="io.nekohasekai.sagernet.ui.profile.HysteriaSettingsActivity"
|
||||
android:configChanges="uiMode" />
|
||||
<activity
|
||||
android:name="io.nekohasekai.sagernet.ui.profile.SSHSettingsActivity"
|
||||
android:configChanges="uiMode" />
|
||||
<activity
|
||||
android:name="io.nekohasekai.sagernet.ui.profile.WireGuardSettingsActivity"
|
||||
android:configChanges="uiMode" />
|
||||
<activity
|
||||
android:name="io.nekohasekai.sagernet.ui.profile.TuicSettingsActivity"
|
||||
android:configChanges="uiMode" />
|
||||
<activity
|
||||
android:name="io.nekohasekai.sagernet.ui.profile.ChainSettingsActivity"
|
||||
android:configChanges="uiMode" />
|
||||
<activity
|
||||
android:name="moe.matsuri.nb4a.proxy.neko.NekoSettingActivity"
|
||||
android:configChanges="uiMode" />
|
||||
<activity
|
||||
android:name="moe.matsuri.nb4a.proxy.config.ConfigSettingActivity"
|
||||
android:configChanges="uiMode" />
|
||||
<activity
|
||||
android:name="io.nekohasekai.sagernet.ui.GroupSettingsActivity"
|
||||
android:configChanges="uiMode" />
|
||||
<activity
|
||||
android:name="io.nekohasekai.sagernet.ui.RouteSettingsActivity"
|
||||
android:configChanges="uiMode" />
|
||||
<activity
|
||||
android:name="io.nekohasekai.sagernet.ui.AssetsActivity"
|
||||
android:configChanges="uiMode" />
|
||||
<activity
|
||||
android:name="io.nekohasekai.sagernet.ui.AppListActivity"
|
||||
android:configChanges="uiMode" />
|
||||
<activity
|
||||
android:name=".QuickToggleShortcut"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="true"
|
||||
android:label="@string/quick_toggle"
|
||||
android:launchMode="singleTask"
|
||||
android:process=":bg"
|
||||
android:taskAffinity=""
|
||||
android:theme="@android:style/Theme.Translucent.NoTitleBar">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.CREATE_SHORTCUT" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name="io.nekohasekai.sagernet.ui.AppManagerActivity"
|
||||
android:configChanges="uiMode"
|
||||
android:excludeFromRecents="true"
|
||||
android:label="@string/proxied_apps"
|
||||
android:launchMode="singleTask"
|
||||
android:parentActivityName=".ui.MainActivity" />
|
||||
<activity
|
||||
android:name="io.nekohasekai.sagernet.ui.ScannerActivity"
|
||||
android:configChanges="uiMode"
|
||||
android:excludeFromRecents="true"
|
||||
android:label="@string/add_profile_methods_scan_qr_code"
|
||||
android:launchMode="singleTask"
|
||||
android:parentActivityName="io.nekohasekai.sagernet.ui.MainActivity" />
|
||||
<activity
|
||||
android:name="io.nekohasekai.sagernet.ui.ProfileSelectActivity"
|
||||
android:configChanges="uiMode"
|
||||
android:label="@string/select_profile"
|
||||
android:launchMode="singleTask"
|
||||
android:parentActivityName="io.nekohasekai.sagernet.ui.MainActivity" />
|
||||
<activity
|
||||
android:name="io.nekohasekai.sagernet.ui.StunActivity"
|
||||
android:configChanges="uiMode"
|
||||
android:launchMode="singleTask"
|
||||
android:parentActivityName="io.nekohasekai.sagernet.ui.MainActivity" />
|
||||
|
||||
<activity
|
||||
android:name="io.nekohasekai.sagernet.ui.SwitchActivity"
|
||||
android:configChanges="uiMode"
|
||||
android:excludeFromRecents="true"
|
||||
android:launchMode="singleInstance"
|
||||
android:theme="@style/Theme.SagerNet.Dialog" />
|
||||
|
||||
<service
|
||||
android:name="io.nekohasekai.sagernet.bg.ProxyService"
|
||||
android:exported="false"
|
||||
android:process=":bg" />
|
||||
|
||||
<service
|
||||
android:name="io.nekohasekai.sagernet.bg.VpnService"
|
||||
android:exported="false"
|
||||
android:label="@string/app_name"
|
||||
android:permission="android.permission.BIND_VPN_SERVICE"
|
||||
android:process=":bg">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.net.VpnService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name="io.nekohasekai.sagernet.bg.TileService"
|
||||
android:exported="true"
|
||||
android:icon="@drawable/ic_service_active"
|
||||
android:label="@string/tile_title"
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
|
||||
android:process=":bg"
|
||||
tools:targetApi="n">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.service.quicksettings.TOGGLEABLE_TILE"
|
||||
android:value="true" />
|
||||
</service>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.cache"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/cache_paths" />
|
||||
</provider>
|
||||
|
||||
<receiver
|
||||
android:name="io.nekohasekai.sagernet.BootReceiver"
|
||||
android:enabled="false"
|
||||
android:exported="true"
|
||||
android:process=":bg">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<provider
|
||||
android:name="androidx.startup.InitializationProvider"
|
||||
android:authorities="${applicationId}.androidx-startup"
|
||||
tools:node="remove" />
|
||||
|
||||
<service
|
||||
android:name="androidx.room.MultiInstanceInvalidationService"
|
||||
android:process=":bg" />
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@ -0,0 +1,13 @@
|
||||
package io.nekohasekai.sagernet.aidl;
|
||||
|
||||
import io.nekohasekai.sagernet.aidl.ISagerNetServiceCallback;
|
||||
|
||||
interface ISagerNetService {
|
||||
int getState();
|
||||
String getProfileName();
|
||||
|
||||
void registerCallback(in ISagerNetServiceCallback cb);
|
||||
oneway void unregisterCallback(in ISagerNetServiceCallback cb);
|
||||
|
||||
int urlTest();
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
package io.nekohasekai.sagernet.aidl;
|
||||
|
||||
import io.nekohasekai.sagernet.aidl.SpeedDisplayData;
|
||||
import io.nekohasekai.sagernet.aidl.TrafficData;
|
||||
|
||||
oneway interface ISagerNetServiceCallback {
|
||||
void stateChanged(int state, String profileName, String msg);
|
||||
void missingPlugin(String profileName, String pluginName);
|
||||
void routeAlert(int type, String routeName);
|
||||
void updateWakeLockStatus(boolean acquired);
|
||||
void cbSpeedUpdate(in SpeedDisplayData stats);
|
||||
void cbTrafficUpdate(in TrafficData stats);
|
||||
void cbLogUpdate(String str);
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
package io.nekohasekai.sagernet.aidl;
|
||||
|
||||
parcelable SpeedDisplayData;
|
||||
@ -0,0 +1,3 @@
|
||||
package io.nekohasekai.sagernet.aidl;
|
||||
|
||||
parcelable TrafficData;
|
||||
19
app/src/main/assets/LICENSE
Normal file
19
app/src/main/assets/LICENSE
Normal file
@ -0,0 +1,19 @@
|
||||
Copyright (C) 2021 by nekohasekai
|
||||
<contact-sagernet@sekai.icu>
|
||||
|
||||
This program is free software: you can
|
||||
redistribute it and/or modify it under
|
||||
the terms of the GNU General Public License
|
||||
as published by the Free Software Foundation,
|
||||
either version 3 of the License,
|
||||
or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope
|
||||
that it will be useful, but WITHOUT ANY WARRANTY;
|
||||
without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
See the GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the
|
||||
GNU General Public License along with this program.
|
||||
If not, see <http://www.gnu.org/licenses/>.
|
||||
5
app/src/main/assets/analysis.txt
Normal file
5
app/src/main/assets/analysis.txt
Normal file
@ -0,0 +1,5 @@
|
||||
domain:appcenter.ms
|
||||
domain:app-measurement.com
|
||||
domain:firebase.io
|
||||
domain:crashlytics.com
|
||||
domain:google-analytics.com
|
||||
1
app/src/main/assets/yacd.version.txt
Normal file
1
app/src/main/assets/yacd.version.txt
Normal file
@ -0,0 +1 @@
|
||||
1
|
||||
BIN
app/src/main/assets/yacd.zip
Normal file
BIN
app/src/main/assets/yacd.zip
Normal file
Binary file not shown.
BIN
app/src/main/ic_launcher-playstore.png
Normal file
BIN
app/src/main/ic_launcher-playstore.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 162 KiB |
9
app/src/main/java/com/github/shadowsocks/plugin/Utils.kt
Normal file
9
app/src/main/java/com/github/shadowsocks/plugin/Utils.kt
Normal file
@ -0,0 +1,9 @@
|
||||
@file:JvmName("Utils")
|
||||
|
||||
package com.github.shadowsocks.plugin
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
class Empty : Parcelable
|
||||
@ -0,0 +1,60 @@
|
||||
package com.github.shadowsocks.plugin.fragment
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.DialogInterface
|
||||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatDialogFragment
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.setFragmentResult
|
||||
import androidx.fragment.app.setFragmentResultListener
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
|
||||
/**
|
||||
* Based on: https://android.googlesource.com/platform/
|
||||
packages/apps/ExactCalculator/+/8c43f06/src/com/android/calculator2/AlertDialogFragment.java
|
||||
*/
|
||||
abstract class AlertDialogFragment<Arg : Parcelable, Ret : Parcelable?> :
|
||||
AppCompatDialogFragment(), DialogInterface.OnClickListener {
|
||||
companion object {
|
||||
private const val KEY_RESULT = "result"
|
||||
private const val KEY_ARG = "arg"
|
||||
private const val KEY_RET = "ret"
|
||||
private const val KEY_WHICH = "which"
|
||||
|
||||
fun <Ret : Parcelable> setResultListener(fragment: Fragment, requestKey: String,
|
||||
listener: (Int, Ret?) -> Unit) {
|
||||
fragment.setFragmentResultListener(requestKey) { _, bundle ->
|
||||
listener(bundle.getInt(KEY_WHICH, Activity.RESULT_CANCELED), bundle.getParcelable(KEY_RET))
|
||||
}
|
||||
}
|
||||
inline fun <reified T : AlertDialogFragment<*, Ret>, Ret : Parcelable?> setResultListener(
|
||||
fragment: Fragment, noinline listener: (Int, Ret?) -> Unit) =
|
||||
setResultListener(fragment, T::class.java.name, listener)
|
||||
}
|
||||
protected abstract fun AlertDialog.Builder.prepare(listener: DialogInterface.OnClickListener)
|
||||
|
||||
private val resultKey get() = requireArguments().getString(KEY_RESULT)
|
||||
protected val arg by lazy { requireArguments().getParcelable<Arg>(KEY_ARG)!! }
|
||||
protected open fun ret(which: Int): Ret? = null
|
||||
|
||||
private fun args() = arguments ?: Bundle().also { arguments = it }
|
||||
fun arg(arg: Arg) = args().putParcelable(KEY_ARG, arg)
|
||||
fun key(resultKey: String = javaClass.name) = args().putString(KEY_RESULT, resultKey)
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): AlertDialog =
|
||||
MaterialAlertDialogBuilder(requireContext()).also { it.prepare(this) }.create()
|
||||
|
||||
override fun onClick(dialog: DialogInterface?, which: Int) {
|
||||
setFragmentResult(resultKey ?: return, Bundle().apply {
|
||||
putInt(KEY_WHICH, which)
|
||||
putParcelable(KEY_RET, ret(which) ?: return@apply)
|
||||
})
|
||||
}
|
||||
|
||||
override fun onDismiss(dialog: DialogInterface) {
|
||||
super.onDismiss(dialog)
|
||||
onClick(null, Activity.RESULT_CANCELED)
|
||||
}
|
||||
}
|
||||
497
app/src/main/java/com/wireguard/crypto/Curve25519.java
Normal file
497
app/src/main/java/com/wireguard/crypto/Curve25519.java
Normal file
@ -0,0 +1,497 @@
|
||||
/*
|
||||
* Copyright © 2016 Southern Storm Software, Pty Ltd.
|
||||
* Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.wireguard.crypto;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* Implementation of Curve25519 ECDH.
|
||||
* <p>
|
||||
* This implementation was imported to WireGuard from noise-java:
|
||||
* https://github.com/rweather/noise-java
|
||||
* <p>
|
||||
* This implementation is based on that from arduinolibs:
|
||||
* https://github.com/rweather/arduinolibs
|
||||
* <p>
|
||||
* Differences in this version are due to using 26-bit limbs for the
|
||||
* representation instead of the 8/16/32-bit limbs in the original.
|
||||
* <p>
|
||||
* References: http://cr.yp.to/ecdh.html, RFC 7748
|
||||
*/
|
||||
@SuppressWarnings({"MagicNumber", "NonConstantFieldWithUpperCaseName", "SuspiciousNameCombination"})
|
||||
public final class Curve25519 {
|
||||
// Numbers modulo 2^255 - 19 are broken up into ten 26-bit words.
|
||||
private static final int NUM_LIMBS_255BIT = 10;
|
||||
private static final int NUM_LIMBS_510BIT = 20;
|
||||
|
||||
private final int[] A;
|
||||
private final int[] AA;
|
||||
private final int[] B;
|
||||
private final int[] BB;
|
||||
private final int[] C;
|
||||
private final int[] CB;
|
||||
private final int[] D;
|
||||
private final int[] DA;
|
||||
private final int[] E;
|
||||
private final long[] t1;
|
||||
private final int[] t2;
|
||||
private final int[] x_1;
|
||||
private final int[] x_2;
|
||||
private final int[] x_3;
|
||||
private final int[] z_2;
|
||||
private final int[] z_3;
|
||||
|
||||
/**
|
||||
* Constructs the temporary state holder for Curve25519 evaluation.
|
||||
*/
|
||||
private Curve25519() {
|
||||
// Allocate memory for all of the temporary variables we will need.
|
||||
x_1 = new int[NUM_LIMBS_255BIT];
|
||||
x_2 = new int[NUM_LIMBS_255BIT];
|
||||
x_3 = new int[NUM_LIMBS_255BIT];
|
||||
z_2 = new int[NUM_LIMBS_255BIT];
|
||||
z_3 = new int[NUM_LIMBS_255BIT];
|
||||
A = new int[NUM_LIMBS_255BIT];
|
||||
B = new int[NUM_LIMBS_255BIT];
|
||||
C = new int[NUM_LIMBS_255BIT];
|
||||
D = new int[NUM_LIMBS_255BIT];
|
||||
E = new int[NUM_LIMBS_255BIT];
|
||||
AA = new int[NUM_LIMBS_255BIT];
|
||||
BB = new int[NUM_LIMBS_255BIT];
|
||||
DA = new int[NUM_LIMBS_255BIT];
|
||||
CB = new int[NUM_LIMBS_255BIT];
|
||||
t1 = new long[NUM_LIMBS_510BIT];
|
||||
t2 = new int[NUM_LIMBS_510BIT];
|
||||
}
|
||||
|
||||
/**
|
||||
* Conditional swap of two values.
|
||||
*
|
||||
* @param select Set to 1 to swap, 0 to leave as-is.
|
||||
* @param x The first value.
|
||||
* @param y The second value.
|
||||
*/
|
||||
private static void cswap(int select, final int[] x, final int[] y) {
|
||||
select = -select;
|
||||
for (int index = 0; index < NUM_LIMBS_255BIT; ++index) {
|
||||
final int dummy = select & (x[index] ^ y[index]);
|
||||
x[index] ^= dummy;
|
||||
y[index] ^= dummy;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates the Curve25519 curve.
|
||||
*
|
||||
* @param result Buffer to place the result of the evaluation into.
|
||||
* @param offset Offset into the result buffer.
|
||||
* @param privateKey The private key to use in the evaluation.
|
||||
* @param publicKey The public key to use in the evaluation, or null
|
||||
* if the base point of the curve should be used.
|
||||
*/
|
||||
public static void eval(final byte[] result, final int offset,
|
||||
final byte[] privateKey, @Nullable final byte[] publicKey) {
|
||||
final Curve25519 state = new Curve25519();
|
||||
try {
|
||||
// Unpack the public key value. If null, use 9 as the base point.
|
||||
Arrays.fill(state.x_1, 0);
|
||||
if (publicKey != null) {
|
||||
// Convert the input value from little-endian into 26-bit limbs.
|
||||
for (int index = 0; index < 32; ++index) {
|
||||
final int bit = (index * 8) % 26;
|
||||
final int word = (index * 8) / 26;
|
||||
final int value = publicKey[index] & 0xFF;
|
||||
if (bit <= (26 - 8)) {
|
||||
state.x_1[word] |= value << bit;
|
||||
} else {
|
||||
state.x_1[word] |= value << bit;
|
||||
state.x_1[word] &= 0x03FFFFFF;
|
||||
state.x_1[word + 1] |= value >> (26 - bit);
|
||||
}
|
||||
}
|
||||
|
||||
// Just in case, we reduce the number modulo 2^255 - 19 to
|
||||
// make sure that it is in range of the field before we start.
|
||||
// This eliminates values between 2^255 - 19 and 2^256 - 1.
|
||||
state.reduceQuick(state.x_1);
|
||||
state.reduceQuick(state.x_1);
|
||||
} else {
|
||||
state.x_1[0] = 9;
|
||||
}
|
||||
|
||||
// Initialize the other temporary variables.
|
||||
Arrays.fill(state.x_2, 0); // x_2 = 1
|
||||
state.x_2[0] = 1;
|
||||
Arrays.fill(state.z_2, 0); // z_2 = 0
|
||||
System.arraycopy(state.x_1, 0, state.x_3, 0, state.x_1.length); // x_3 = x_1
|
||||
Arrays.fill(state.z_3, 0); // z_3 = 1
|
||||
state.z_3[0] = 1;
|
||||
|
||||
// Evaluate the curve for every bit of the private key.
|
||||
state.evalCurve(privateKey);
|
||||
|
||||
// Compute x_2 * (z_2 ^ (p - 2)) where p = 2^255 - 19.
|
||||
state.recip(state.z_3, state.z_2);
|
||||
state.mul(state.x_2, state.x_2, state.z_3);
|
||||
|
||||
// Convert x_2 into little-endian in the result buffer.
|
||||
for (int index = 0; index < 32; ++index) {
|
||||
final int bit = (index * 8) % 26;
|
||||
final int word = (index * 8) / 26;
|
||||
if (bit <= (26 - 8))
|
||||
result[offset + index] = (byte) (state.x_2[word] >> bit);
|
||||
else
|
||||
result[offset + index] = (byte) ((state.x_2[word] >> bit) | (state.x_2[word + 1] << (26 - bit)));
|
||||
}
|
||||
} finally {
|
||||
// Clean up all temporary state before we exit.
|
||||
state.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subtracts two numbers modulo 2^255 - 19.
|
||||
*
|
||||
* @param result The result.
|
||||
* @param x The first number to subtract.
|
||||
* @param y The second number to subtract.
|
||||
*/
|
||||
private static void sub(final int[] result, final int[] x, final int[] y) {
|
||||
int index;
|
||||
int borrow;
|
||||
|
||||
// Subtract y from x to generate the intermediate result.
|
||||
borrow = 0;
|
||||
for (index = 0; index < NUM_LIMBS_255BIT; ++index) {
|
||||
borrow = x[index] - y[index] - ((borrow >> 26) & 0x01);
|
||||
result[index] = borrow & 0x03FFFFFF;
|
||||
}
|
||||
|
||||
// If we had a borrow, then the result has gone negative and we
|
||||
// have to add 2^255 - 19 to the result to make it positive again.
|
||||
// The top bits of "borrow" will be all 1's if there is a borrow
|
||||
// or it will be all 0's if there was no borrow. Easiest is to
|
||||
// conditionally subtract 19 and then mask off the high bits.
|
||||
borrow = result[0] - ((-((borrow >> 26) & 0x01)) & 19);
|
||||
result[0] = borrow & 0x03FFFFFF;
|
||||
for (index = 1; index < NUM_LIMBS_255BIT; ++index) {
|
||||
borrow = result[index] - ((borrow >> 26) & 0x01);
|
||||
result[index] = borrow & 0x03FFFFFF;
|
||||
}
|
||||
result[NUM_LIMBS_255BIT - 1] &= 0x001FFFFF;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds two numbers modulo 2^255 - 19.
|
||||
*
|
||||
* @param result The result.
|
||||
* @param x The first number to add.
|
||||
* @param y The second number to add.
|
||||
*/
|
||||
private void add(final int[] result, final int[] x, final int[] y) {
|
||||
int carry = x[0] + y[0];
|
||||
result[0] = carry & 0x03FFFFFF;
|
||||
for (int index = 1; index < NUM_LIMBS_255BIT; ++index) {
|
||||
carry = (carry >> 26) + x[index] + y[index];
|
||||
result[index] = carry & 0x03FFFFFF;
|
||||
}
|
||||
reduceQuick(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy all sensitive data in this object.
|
||||
*/
|
||||
private void destroy() {
|
||||
// Destroy all temporary variables.
|
||||
Arrays.fill(x_1, 0);
|
||||
Arrays.fill(x_2, 0);
|
||||
Arrays.fill(x_3, 0);
|
||||
Arrays.fill(z_2, 0);
|
||||
Arrays.fill(z_3, 0);
|
||||
Arrays.fill(A, 0);
|
||||
Arrays.fill(B, 0);
|
||||
Arrays.fill(C, 0);
|
||||
Arrays.fill(D, 0);
|
||||
Arrays.fill(E, 0);
|
||||
Arrays.fill(AA, 0);
|
||||
Arrays.fill(BB, 0);
|
||||
Arrays.fill(DA, 0);
|
||||
Arrays.fill(CB, 0);
|
||||
Arrays.fill(t1, 0L);
|
||||
Arrays.fill(t2, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates the curve for every bit in a secret key.
|
||||
*
|
||||
* @param s The 32-byte secret key.
|
||||
*/
|
||||
private void evalCurve(final byte[] s) {
|
||||
int sposn = 31;
|
||||
int sbit = 6;
|
||||
int svalue = s[sposn] | 0x40;
|
||||
int swap = 0;
|
||||
|
||||
// Iterate over all 255 bits of "s" from the highest to the lowest.
|
||||
// We ignore the high bit of the 256-bit representation of "s".
|
||||
while (true) {
|
||||
// Conditional swaps on entry to this bit but only if we
|
||||
// didn't swap on the previous bit.
|
||||
final int select = (svalue >> sbit) & 0x01;
|
||||
swap ^= select;
|
||||
cswap(swap, x_2, x_3);
|
||||
cswap(swap, z_2, z_3);
|
||||
swap = select;
|
||||
|
||||
// Evaluate the curve.
|
||||
add(A, x_2, z_2); // A = x_2 + z_2
|
||||
square(AA, A); // AA = A^2
|
||||
sub(B, x_2, z_2); // B = x_2 - z_2
|
||||
square(BB, B); // BB = B^2
|
||||
sub(E, AA, BB); // E = AA - BB
|
||||
add(C, x_3, z_3); // C = x_3 + z_3
|
||||
sub(D, x_3, z_3); // D = x_3 - z_3
|
||||
mul(DA, D, A); // DA = D * A
|
||||
mul(CB, C, B); // CB = C * B
|
||||
add(x_3, DA, CB); // x_3 = (DA + CB)^2
|
||||
square(x_3, x_3);
|
||||
sub(z_3, DA, CB); // z_3 = x_1 * (DA - CB)^2
|
||||
square(z_3, z_3);
|
||||
mul(z_3, z_3, x_1);
|
||||
mul(x_2, AA, BB); // x_2 = AA * BB
|
||||
mulA24(z_2, E); // z_2 = E * (AA + a24 * E)
|
||||
add(z_2, z_2, AA);
|
||||
mul(z_2, z_2, E);
|
||||
|
||||
// Move onto the next lower bit of "s".
|
||||
if (sbit > 0) {
|
||||
--sbit;
|
||||
} else if (sposn == 0) {
|
||||
break;
|
||||
} else if (sposn == 1) {
|
||||
--sposn;
|
||||
svalue = s[sposn] & 0xF8;
|
||||
sbit = 7;
|
||||
} else {
|
||||
--sposn;
|
||||
svalue = s[sposn];
|
||||
sbit = 7;
|
||||
}
|
||||
}
|
||||
|
||||
// Final conditional swaps.
|
||||
cswap(swap, x_2, x_3);
|
||||
cswap(swap, z_2, z_3);
|
||||
}
|
||||
|
||||
/**
|
||||
* Multiplies two numbers modulo 2^255 - 19.
|
||||
*
|
||||
* @param result The result.
|
||||
* @param x The first number to multiply.
|
||||
* @param y The second number to multiply.
|
||||
*/
|
||||
private void mul(final int[] result, final int[] x, final int[] y) {
|
||||
// Multiply the two numbers to create the intermediate result.
|
||||
long v = x[0];
|
||||
for (int i = 0; i < NUM_LIMBS_255BIT; ++i) {
|
||||
t1[i] = v * y[i];
|
||||
}
|
||||
for (int i = 1; i < NUM_LIMBS_255BIT; ++i) {
|
||||
v = x[i];
|
||||
for (int j = 0; j < (NUM_LIMBS_255BIT - 1); ++j) {
|
||||
t1[i + j] += v * y[j];
|
||||
}
|
||||
t1[i + NUM_LIMBS_255BIT - 1] = v * y[NUM_LIMBS_255BIT - 1];
|
||||
}
|
||||
|
||||
// Propagate carries and convert back into 26-bit words.
|
||||
v = t1[0];
|
||||
t2[0] = ((int) v) & 0x03FFFFFF;
|
||||
for (int i = 1; i < NUM_LIMBS_510BIT; ++i) {
|
||||
v = (v >> 26) + t1[i];
|
||||
t2[i] = ((int) v) & 0x03FFFFFF;
|
||||
}
|
||||
|
||||
// Reduce the result modulo 2^255 - 19.
|
||||
reduce(result, t2, NUM_LIMBS_255BIT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Multiplies a number by the a24 constant, modulo 2^255 - 19.
|
||||
*
|
||||
* @param result The result.
|
||||
* @param x The number to multiply by a24.
|
||||
*/
|
||||
private void mulA24(final int[] result, final int[] x) {
|
||||
final long a24 = 121665;
|
||||
long carry = 0;
|
||||
for (int index = 0; index < NUM_LIMBS_255BIT; ++index) {
|
||||
carry += a24 * x[index];
|
||||
t2[index] = ((int) carry) & 0x03FFFFFF;
|
||||
carry >>= 26;
|
||||
}
|
||||
t2[NUM_LIMBS_255BIT] = ((int) carry) & 0x03FFFFFF;
|
||||
reduce(result, t2, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Raise x to the power of (2^250 - 1).
|
||||
*
|
||||
* @param result The result. Must not overlap with x.
|
||||
* @param x The argument.
|
||||
*/
|
||||
private void pow250(final int[] result, final int[] x) {
|
||||
// The big-endian hexadecimal expansion of (2^250 - 1) is:
|
||||
// 03FFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF
|
||||
//
|
||||
// The naive implementation needs to do 2 multiplications per 1 bit and
|
||||
// 1 multiplication per 0 bit. We can improve upon this by creating a
|
||||
// pattern 0000000001 ... 0000000001. If we square and multiply the
|
||||
// pattern by itself we can turn the pattern into the partial results
|
||||
// 0000000011 ... 0000000011, 0000000111 ... 0000000111, etc.
|
||||
// This averages out to about 1.1 multiplications per 1 bit instead of 2.
|
||||
|
||||
// Build a pattern of 250 bits in length of repeated copies of 0000000001.
|
||||
square(A, x);
|
||||
for (int j = 0; j < 9; ++j)
|
||||
square(A, A);
|
||||
mul(result, A, x);
|
||||
for (int i = 0; i < 23; ++i) {
|
||||
for (int j = 0; j < 10; ++j)
|
||||
square(A, A);
|
||||
mul(result, result, A);
|
||||
}
|
||||
|
||||
// Multiply bit-shifted versions of the 0000000001 pattern into
|
||||
// the result to "fill in" the gaps in the pattern.
|
||||
square(A, result);
|
||||
mul(result, result, A);
|
||||
for (int j = 0; j < 8; ++j) {
|
||||
square(A, A);
|
||||
mul(result, result, A);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the reciprocal of a number modulo 2^255 - 19.
|
||||
*
|
||||
* @param result The result. Must not overlap with x.
|
||||
* @param x The argument.
|
||||
*/
|
||||
private void recip(final int[] result, final int[] x) {
|
||||
// The reciprocal is the same as x ^ (p - 2) where p = 2^255 - 19.
|
||||
// The big-endian hexadecimal expansion of (p - 2) is:
|
||||
// 7FFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFFF FFFFFFEB
|
||||
// Start with the 250 upper bits of the expansion of (p - 2).
|
||||
pow250(result, x);
|
||||
|
||||
// Deal with the 5 lowest bits of (p - 2), 01011, from highest to lowest.
|
||||
square(result, result);
|
||||
square(result, result);
|
||||
mul(result, result, x);
|
||||
square(result, result);
|
||||
square(result, result);
|
||||
mul(result, result, x);
|
||||
square(result, result);
|
||||
mul(result, result, x);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce a number modulo 2^255 - 19.
|
||||
*
|
||||
* @param result The result.
|
||||
* @param x The value to be reduced. This array will be
|
||||
* modified during the reduction.
|
||||
* @param size The number of limbs in the high order half of x.
|
||||
*/
|
||||
private void reduce(final int[] result, final int[] x, final int size) {
|
||||
// Calculate (x mod 2^255) + ((x / 2^255) * 19) which will
|
||||
// either produce the answer we want or it will produce a
|
||||
// value of the form "answer + j * (2^255 - 19)". There are
|
||||
// 5 left-over bits in the top-most limb of the bottom half.
|
||||
int carry = 0;
|
||||
int limb = x[NUM_LIMBS_255BIT - 1] >> 21;
|
||||
x[NUM_LIMBS_255BIT - 1] &= 0x001FFFFF;
|
||||
for (int index = 0; index < size; ++index) {
|
||||
limb += x[NUM_LIMBS_255BIT + index] << 5;
|
||||
carry += (limb & 0x03FFFFFF) * 19 + x[index];
|
||||
x[index] = carry & 0x03FFFFFF;
|
||||
limb >>= 26;
|
||||
carry >>= 26;
|
||||
}
|
||||
if (size < NUM_LIMBS_255BIT) {
|
||||
// The high order half of the number is short; e.g. for mulA24().
|
||||
// Propagate the carry through the rest of the low order part.
|
||||
for (int index = size; index < NUM_LIMBS_255BIT; ++index) {
|
||||
carry += x[index];
|
||||
x[index] = carry & 0x03FFFFFF;
|
||||
carry >>= 26;
|
||||
}
|
||||
}
|
||||
|
||||
// The "j" value may still be too large due to the final carry-out.
|
||||
// We must repeat the reduction. If we already have the answer,
|
||||
// then this won't do any harm but we must still do the calculation
|
||||
// to preserve the overall timing. The "j" value will be between
|
||||
// 0 and 19, which means that the carry we care about is in the
|
||||
// top 5 bits of the highest limb of the bottom half.
|
||||
carry = (x[NUM_LIMBS_255BIT - 1] >> 21) * 19;
|
||||
x[NUM_LIMBS_255BIT - 1] &= 0x001FFFFF;
|
||||
for (int index = 0; index < NUM_LIMBS_255BIT; ++index) {
|
||||
carry += x[index];
|
||||
result[index] = carry & 0x03FFFFFF;
|
||||
carry >>= 26;
|
||||
}
|
||||
|
||||
// At this point "x" will either be the answer or it will be the
|
||||
// answer plus (2^255 - 19). Perform a trial subtraction to
|
||||
// complete the reduction process.
|
||||
reduceQuick(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduces a number modulo 2^255 - 19 where it is known that the
|
||||
* number can be reduced with only 1 trial subtraction.
|
||||
*
|
||||
* @param x The number to reduce, and the result.
|
||||
*/
|
||||
private void reduceQuick(final int[] x) {
|
||||
// Perform a trial subtraction of (2^255 - 19) from "x" which is
|
||||
// equivalent to adding 19 and subtracting 2^255. We add 19 here;
|
||||
// the subtraction of 2^255 occurs in the next step.
|
||||
int carry = 19;
|
||||
for (int index = 0; index < NUM_LIMBS_255BIT; ++index) {
|
||||
carry += x[index];
|
||||
t2[index] = carry & 0x03FFFFFF;
|
||||
carry >>= 26;
|
||||
}
|
||||
|
||||
// If there was a borrow, then the original "x" is the correct answer.
|
||||
// If there was no borrow, then "t2" is the correct answer. Select the
|
||||
// correct answer but do it in a way that instruction timing will not
|
||||
// reveal which value was selected. Borrow will occur if bit 21 of
|
||||
// "t2" is zero. Turn the bit into a selection mask.
|
||||
final int mask = -((t2[NUM_LIMBS_255BIT - 1] >> 21) & 0x01);
|
||||
final int nmask = ~mask;
|
||||
t2[NUM_LIMBS_255BIT - 1] &= 0x001FFFFF;
|
||||
for (int index = 0; index < NUM_LIMBS_255BIT; ++index)
|
||||
x[index] = (x[index] & nmask) | (t2[index] & mask);
|
||||
}
|
||||
|
||||
/**
|
||||
* Squares a number modulo 2^255 - 19.
|
||||
*
|
||||
* @param result The result.
|
||||
* @param x The number to square.
|
||||
*/
|
||||
private void square(final int[] result, final int[] x) {
|
||||
mul(result, x, x);
|
||||
}
|
||||
}
|
||||
2508
app/src/main/java/com/wireguard/crypto/Ed25519.java
Normal file
2508
app/src/main/java/com/wireguard/crypto/Ed25519.java
Normal file
File diff suppressed because it is too large
Load Diff
288
app/src/main/java/com/wireguard/crypto/Key.java
Normal file
288
app/src/main/java/com/wireguard/crypto/Key.java
Normal file
@ -0,0 +1,288 @@
|
||||
/*
|
||||
* Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.wireguard.crypto;
|
||||
|
||||
import com.wireguard.crypto.KeyFormatException.Type;
|
||||
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* Represents a WireGuard public or private key. This class uses specialized constant-time base64
|
||||
* and hexadecimal codec implementations that resist side-channel attacks.
|
||||
* <p>
|
||||
* Instances of this class are immutable.
|
||||
*/
|
||||
@SuppressWarnings("MagicNumber")
|
||||
public final class Key {
|
||||
private final byte[] key;
|
||||
|
||||
/**
|
||||
* Constructs an object encapsulating the supplied key.
|
||||
*
|
||||
* @param key an array of bytes containing a binary key. Callers of this constructor are
|
||||
* responsible for ensuring that the array is of the correct length.
|
||||
*/
|
||||
private Key(final byte[] key) {
|
||||
// Defensively copy to ensure immutability.
|
||||
this.key = Arrays.copyOf(key, key.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a single 4-character base64 chunk to an integer in constant time.
|
||||
*
|
||||
* @param src an array of at least 4 characters in base64 format
|
||||
* @param srcOffset the offset of the beginning of the chunk in {@code src}
|
||||
* @return the decoded 3-byte integer, or some arbitrary integer value if the input was not
|
||||
* valid base64
|
||||
*/
|
||||
private static int decodeBase64(final char[] src, final int srcOffset) {
|
||||
int val = 0;
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
final char c = src[i + srcOffset];
|
||||
val |= (-1
|
||||
+ ((((('A' - 1) - c) & (c - ('Z' + 1))) >>> 8) & (c - 64))
|
||||
+ ((((('a' - 1) - c) & (c - ('z' + 1))) >>> 8) & (c - 70))
|
||||
+ ((((('0' - 1) - c) & (c - ('9' + 1))) >>> 8) & (c + 5))
|
||||
+ ((((('+' - 1) - c) & (c - ('+' + 1))) >>> 8) & 63)
|
||||
+ ((((('/' - 1) - c) & (c - ('/' + 1))) >>> 8) & 64)
|
||||
) << (18 - 6 * i);
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes a single 4-character base64 chunk from 3 consecutive bytes in constant time.
|
||||
*
|
||||
* @param src an array of at least 3 bytes
|
||||
* @param srcOffset the offset of the beginning of the chunk in {@code src}
|
||||
* @param dest an array of at least 4 characters
|
||||
* @param destOffset the offset of the beginning of the chunk in {@code dest}
|
||||
*/
|
||||
private static void encodeBase64(final byte[] src, final int srcOffset,
|
||||
final char[] dest, final int destOffset) {
|
||||
final byte[] input = {
|
||||
(byte) ((src[srcOffset] >>> 2) & 63),
|
||||
(byte) ((src[srcOffset] << 4 | ((src[1 + srcOffset] & 0xff) >>> 4)) & 63),
|
||||
(byte) ((src[1 + srcOffset] << 2 | ((src[2 + srcOffset] & 0xff) >>> 6)) & 63),
|
||||
(byte) ((src[2 + srcOffset]) & 63),
|
||||
};
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
dest[i + destOffset] = (char) (input[i] + 'A'
|
||||
+ (((25 - input[i]) >>> 8) & 6)
|
||||
- (((51 - input[i]) >>> 8) & 75)
|
||||
- (((61 - input[i]) >>> 8) & 15)
|
||||
+ (((62 - input[i]) >>> 8) & 3));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a WireGuard public or private key from its base64 string representation. This
|
||||
* function throws a {@link KeyFormatException} if the source string is not well-formed.
|
||||
*
|
||||
* @param str the base64 string representation of a WireGuard key
|
||||
* @return the decoded key encapsulated in an immutable container
|
||||
*/
|
||||
public static Key fromBase64(final String str) throws KeyFormatException {
|
||||
final char[] input = str.toCharArray();
|
||||
if (input.length != Format.BASE64.length || input[Format.BASE64.length - 1] != '=')
|
||||
throw new KeyFormatException(Format.BASE64, Type.LENGTH);
|
||||
final byte[] key = new byte[Format.BINARY.length];
|
||||
int i;
|
||||
int ret = 0;
|
||||
for (i = 0; i < key.length / 3; ++i) {
|
||||
final int val = decodeBase64(input, i * 4);
|
||||
ret |= val >>> 31;
|
||||
key[i * 3] = (byte) ((val >>> 16) & 0xff);
|
||||
key[i * 3 + 1] = (byte) ((val >>> 8) & 0xff);
|
||||
key[i * 3 + 2] = (byte) (val & 0xff);
|
||||
}
|
||||
final char[] endSegment = {
|
||||
input[i * 4],
|
||||
input[i * 4 + 1],
|
||||
input[i * 4 + 2],
|
||||
'A',
|
||||
};
|
||||
final int val = decodeBase64(endSegment, 0);
|
||||
ret |= (val >>> 31) | (val & 0xff);
|
||||
key[i * 3] = (byte) ((val >>> 16) & 0xff);
|
||||
key[i * 3 + 1] = (byte) ((val >>> 8) & 0xff);
|
||||
|
||||
if (ret != 0)
|
||||
throw new KeyFormatException(Format.BASE64, Type.CONTENTS);
|
||||
return new Key(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a WireGuard public or private key in an immutable container. This function throws a
|
||||
* {@link KeyFormatException} if the source data is not the correct length.
|
||||
*
|
||||
* @param bytes an array of bytes containing a WireGuard key in binary format
|
||||
* @return the key encapsulated in an immutable container
|
||||
*/
|
||||
public static Key fromBytes(final byte[] bytes) throws KeyFormatException {
|
||||
if (bytes.length != Format.BINARY.length)
|
||||
throw new KeyFormatException(Format.BINARY, Type.LENGTH);
|
||||
return new Key(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a WireGuard public or private key from its hexadecimal string representation. This
|
||||
* function throws a {@link KeyFormatException} if the source string is not well-formed.
|
||||
*
|
||||
* @param str the hexadecimal string representation of a WireGuard key
|
||||
* @return the decoded key encapsulated in an immutable container
|
||||
*/
|
||||
public static Key fromHex(final String str) throws KeyFormatException {
|
||||
final char[] input = str.toCharArray();
|
||||
if (input.length != Format.HEX.length)
|
||||
throw new KeyFormatException(Format.HEX, Type.LENGTH);
|
||||
final byte[] key = new byte[Format.BINARY.length];
|
||||
int ret = 0;
|
||||
for (int i = 0; i < key.length; ++i) {
|
||||
int c;
|
||||
int cNum;
|
||||
int cNum0;
|
||||
int cAlpha;
|
||||
int cAlpha0;
|
||||
int cVal;
|
||||
final int cAcc;
|
||||
|
||||
c = input[i * 2];
|
||||
cNum = c ^ 48;
|
||||
cNum0 = ((cNum - 10) >>> 8) & 0xff;
|
||||
cAlpha = (c & ~32) - 55;
|
||||
cAlpha0 = (((cAlpha - 10) ^ (cAlpha - 16)) >>> 8) & 0xff;
|
||||
ret |= ((cNum0 | cAlpha0) - 1) >>> 8;
|
||||
cVal = (cNum0 & cNum) | (cAlpha0 & cAlpha);
|
||||
cAcc = cVal * 16;
|
||||
|
||||
c = input[i * 2 + 1];
|
||||
cNum = c ^ 48;
|
||||
cNum0 = ((cNum - 10) >>> 8) & 0xff;
|
||||
cAlpha = (c & ~32) - 55;
|
||||
cAlpha0 = (((cAlpha - 10) ^ (cAlpha - 16)) >>> 8) & 0xff;
|
||||
ret |= ((cNum0 | cAlpha0) - 1) >>> 8;
|
||||
cVal = (cNum0 & cNum) | (cAlpha0 & cAlpha);
|
||||
key[i] = (byte) (cAcc | cVal);
|
||||
}
|
||||
if (ret != 0)
|
||||
throw new KeyFormatException(Format.HEX, Type.CONTENTS);
|
||||
return new Key(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a private key using the system's {@link SecureRandom} number generator.
|
||||
*
|
||||
* @return a well-formed random private key
|
||||
*/
|
||||
static Key generatePrivateKey() {
|
||||
final SecureRandom secureRandom = new SecureRandom();
|
||||
final byte[] privateKey = new byte[Format.BINARY.getLength()];
|
||||
secureRandom.nextBytes(privateKey);
|
||||
privateKey[0] &= 248;
|
||||
privateKey[31] &= 127;
|
||||
privateKey[31] |= 64;
|
||||
return new Key(privateKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a public key from an existing private key.
|
||||
*
|
||||
* @param privateKey a private key
|
||||
* @return a well-formed public key that corresponds to the supplied private key
|
||||
*/
|
||||
static Key generatePublicKey(final Key privateKey) {
|
||||
final byte[] publicKey = new byte[Format.BINARY.getLength()];
|
||||
Curve25519.eval(publicKey, 0, privateKey.getBytes(), null);
|
||||
return new Key(publicKey);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(final Object obj) {
|
||||
if (obj == this)
|
||||
return true;
|
||||
if (obj == null || obj.getClass() != getClass())
|
||||
return false;
|
||||
final Key other = (Key) obj;
|
||||
return MessageDigest.isEqual(key, other.key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the key as an array of bytes.
|
||||
*
|
||||
* @return an array of bytes containing the raw binary key
|
||||
*/
|
||||
public byte[] getBytes() {
|
||||
// Defensively copy to ensure immutability.
|
||||
return Arrays.copyOf(key, key.length);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
int ret = 0;
|
||||
for (int i = 0; i < key.length / 4; ++i)
|
||||
ret ^= (key[i * 4 + 0] >> 0) + (key[i * 4 + 1] >> 8) + (key[i * 4 + 2] >> 16) + (key[i * 4 + 3] >> 24);
|
||||
return ret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes the key to base64.
|
||||
*
|
||||
* @return a string containing the encoded key
|
||||
*/
|
||||
public String toBase64() {
|
||||
final char[] output = new char[Format.BASE64.length];
|
||||
int i;
|
||||
for (i = 0; i < key.length / 3; ++i)
|
||||
encodeBase64(key, i * 3, output, i * 4);
|
||||
final byte[] endSegment = {
|
||||
key[i * 3],
|
||||
key[i * 3 + 1],
|
||||
0,
|
||||
};
|
||||
encodeBase64(endSegment, 0, output, i * 4);
|
||||
output[Format.BASE64.length - 1] = '=';
|
||||
return new String(output);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes the key to hexadecimal ASCII characters.
|
||||
*
|
||||
* @return a string containing the encoded key
|
||||
*/
|
||||
public String toHex() {
|
||||
final char[] output = new char[Format.HEX.length];
|
||||
for (int i = 0; i < key.length; ++i) {
|
||||
output[i * 2] = (char) (87 + (key[i] >> 4 & 0xf)
|
||||
+ ((((key[i] >> 4 & 0xf) - 10) >> 8) & ~38));
|
||||
output[i * 2 + 1] = (char) (87 + (key[i] & 0xf)
|
||||
+ ((((key[i] & 0xf) - 10) >> 8) & ~38));
|
||||
}
|
||||
return new String(output);
|
||||
}
|
||||
|
||||
/**
|
||||
* The supported formats for encoding a WireGuard key.
|
||||
*/
|
||||
public enum Format {
|
||||
BASE64(44),
|
||||
BINARY(32),
|
||||
HEX(64);
|
||||
|
||||
private final int length;
|
||||
|
||||
Format(final int length) {
|
||||
this.length = length;
|
||||
}
|
||||
|
||||
public int getLength() {
|
||||
return length;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.wireguard.crypto;
|
||||
|
||||
/**
|
||||
* An exception thrown when attempting to parse an invalid key (too short, too long, or byte
|
||||
* data inappropriate for the format). The format being parsed can be accessed with the
|
||||
* {@link #getFormat} method.
|
||||
*/
|
||||
public final class KeyFormatException extends Exception {
|
||||
private final Key.Format format;
|
||||
private final Type type;
|
||||
|
||||
KeyFormatException(final Key.Format format, final Type type) {
|
||||
this.format = format;
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
public Key.Format getFormat() {
|
||||
return format;
|
||||
}
|
||||
|
||||
public Type getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public enum Type {
|
||||
CONTENTS,
|
||||
LENGTH
|
||||
}
|
||||
}
|
||||
51
app/src/main/java/com/wireguard/crypto/KeyPair.java
Normal file
51
app/src/main/java/com/wireguard/crypto/KeyPair.java
Normal file
@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright © 2017-2019 WireGuard LLC. All Rights Reserved.
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package com.wireguard.crypto;
|
||||
|
||||
/**
|
||||
* Represents a Curve25519 key pair as used by WireGuard.
|
||||
* <p>
|
||||
* Instances of this class are immutable.
|
||||
*/
|
||||
public class KeyPair {
|
||||
private final Key privateKey;
|
||||
private final Key publicKey;
|
||||
|
||||
/**
|
||||
* Creates a key pair using a newly-generated private key.
|
||||
*/
|
||||
public KeyPair() {
|
||||
this(Key.generatePrivateKey());
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a key pair using an existing private key.
|
||||
*
|
||||
* @param privateKey a private key, used to derive the public key
|
||||
*/
|
||||
public KeyPair(final Key privateKey) {
|
||||
this.privateKey = privateKey;
|
||||
publicKey = Key.generatePublicKey(privateKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the private key from the key pair.
|
||||
*
|
||||
* @return the private key
|
||||
*/
|
||||
public Key getPrivateKey() {
|
||||
return privateKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the public key from the key pair.
|
||||
*
|
||||
* @return the public key
|
||||
*/
|
||||
public Key getPublicKey() {
|
||||
return publicKey;
|
||||
}
|
||||
}
|
||||
182
app/src/main/java/io/nekohasekai/sagernet/Constants.kt
Normal file
182
app/src/main/java/io/nekohasekai/sagernet/Constants.kt
Normal file
@ -0,0 +1,182 @@
|
||||
package io.nekohasekai.sagernet
|
||||
|
||||
const val CONNECTION_TEST_URL = "http://cp.cloudflare.com/"
|
||||
|
||||
object Key {
|
||||
|
||||
const val DB_PUBLIC = "configuration.db"
|
||||
const val DB_PROFILE = "sager_net.db"
|
||||
|
||||
const val APP_EXPERT = "isExpert"
|
||||
const val APP_THEME = "appTheme"
|
||||
const val NIGHT_THEME = "nightTheme"
|
||||
const val SERVICE_MODE = "serviceMode"
|
||||
const val MODE_VPN = "vpn"
|
||||
const val MODE_PROXY = "proxy"
|
||||
|
||||
const val REMOTE_DNS = "remoteDns"
|
||||
const val DIRECT_DNS = "directDns"
|
||||
const val DIRECT_DNS_USE_SYSTEM = "directDnsUseSystem"
|
||||
const val ENABLE_DNS_ROUTING = "enableDnsRouting"
|
||||
const val ENABLE_FAKEDNS = "enableFakeDns"
|
||||
const val DNS_NETWORK = "dnsNetwork"
|
||||
|
||||
const val IPV6_MODE = "ipv6Mode"
|
||||
|
||||
const val PROXY_APPS = "proxyApps"
|
||||
const val BYPASS_MODE = "bypassMode"
|
||||
const val INDIVIDUAL = "individual"
|
||||
const val METERED_NETWORK = "meteredNetwork"
|
||||
|
||||
const val DOMAIN_STRATEGY = "domainStrategy"
|
||||
const val TRAFFIC_SNIFFING = "trafficSniffing"
|
||||
const val RESOLVE_DESTINATION = "resolveDestination"
|
||||
|
||||
const val BYPASS_LAN = "bypassLan"
|
||||
const val BYPASS_LAN_IN_CORE_ONLY = "bypassLanInCoreOnly"
|
||||
|
||||
const val MIXED_PORT = "mixedPort"
|
||||
const val ALLOW_ACCESS = "allowAccess"
|
||||
const val SPEED_INTERVAL = "speedInterval"
|
||||
const val SHOW_DIRECT_SPEED = "showDirectSpeed"
|
||||
const val LOCAL_DNS_PORT = "portLocalDns"
|
||||
|
||||
const val APPEND_HTTP_PROXY = "appendHttpProxy"
|
||||
|
||||
const val REQUIRE_TRANSPROXY = "requireTransproxy"
|
||||
const val TRANSPROXY_MODE = "transproxyMode"
|
||||
const val TRANSPROXY_PORT = "transproxyPort"
|
||||
const val CONNECTION_TEST_URL = "connectionTestURL"
|
||||
|
||||
const val TCP_KEEP_ALIVE_INTERVAL = "tcpKeepAliveInterval"
|
||||
const val RULES_PROVIDER = "rulesProvider"
|
||||
const val ENABLE_LOG = "enableLog"
|
||||
const val LOG_BUF_SIZE = "logBufSize"
|
||||
const val MTU = "mtu"
|
||||
const val ALWAYS_SHOW_ADDRESS = "alwaysShowAddress"
|
||||
|
||||
// Protocol Settings
|
||||
const val MUX_PROTOCOLS = "mux"
|
||||
const val MUX_CONCURRENCY = "muxConcurrency"
|
||||
|
||||
const val ACQUIRE_WAKE_LOCK = "acquireWakeLock"
|
||||
const val SHOW_BOTTOM_BAR = "showBottomBar"
|
||||
|
||||
const val TUN_IMPLEMENTATION = "tunImplementation"
|
||||
const val PROFILE_TRAFFIC_STATISTICS = "profileTrafficStatistics"
|
||||
|
||||
const val PROFILE_DIRTY = "profileDirty"
|
||||
const val PROFILE_ID = "profileId"
|
||||
const val PROFILE_NAME = "profileName"
|
||||
const val PROFILE_GROUP = "profileGroup"
|
||||
const val PROFILE_CURRENT = "profileCurrent"
|
||||
|
||||
const val SERVER_ADDRESS = "serverAddress"
|
||||
const val SERVER_PORT = "serverPort"
|
||||
const val SERVER_USERNAME = "serverUsername"
|
||||
const val SERVER_PASSWORD = "serverPassword"
|
||||
const val SERVER_METHOD = "serverMethod"
|
||||
const val SERVER_PLUGIN = "serverPlugin"
|
||||
const val SERVER_PLUGIN_CONFIGURE = "serverPluginConfigure"
|
||||
const val SERVER_PASSWORD1 = "serverPassword1"
|
||||
|
||||
const val SERVER_PROTOCOL = "serverProtocol"
|
||||
const val SERVER_OBFS = "serverObfs"
|
||||
|
||||
const val SERVER_SECURITY = "serverSecurity"
|
||||
const val SERVER_NETWORK = "serverNetwork"
|
||||
const val SERVER_HOST = "serverHost"
|
||||
const val SERVER_PATH = "serverPath"
|
||||
const val SERVER_SNI = "serverSNI"
|
||||
const val SERVER_ENCRYPTION = "serverEncryption"
|
||||
const val SERVER_ALPN = "serverALPN"
|
||||
const val SERVER_CERTIFICATES = "serverCertificates"
|
||||
const val SERVER_CONFIG = "serverConfig"
|
||||
|
||||
const val SERVER_SECURITY_CATEGORY = "serverSecurityCategory"
|
||||
const val SERVER_WS_CATEGORY = "serverWsCategory"
|
||||
const val SERVER_SS_CATEGORY = "serverSsCategory"
|
||||
const val SERVER_HEADERS = "serverHeaders"
|
||||
const val SERVER_ALLOW_INSECURE = "serverAllowInsecure"
|
||||
|
||||
const val SERVER_AUTH_TYPE = "serverAuthType"
|
||||
const val SERVER_UPLOAD_SPEED = "serverUploadSpeed"
|
||||
const val SERVER_DOWNLOAD_SPEED = "serverDownloadSpeed"
|
||||
const val SERVER_STREAM_RECEIVE_WINDOW = "serverStreamReceiveWindow"
|
||||
const val SERVER_CONNECTION_RECEIVE_WINDOW = "serverConnectionReceiveWindow"
|
||||
const val SERVER_MTU = "serverMTU"
|
||||
const val SERVER_DISABLE_MTU_DISCOVERY = "serverDisableMtuDiscovery"
|
||||
const val SERVER_HOP_INTERVAL = "hopInterval"
|
||||
|
||||
const val SERVER_PRIVATE_KEY = "serverPrivateKey"
|
||||
const val SERVER_LOCAL_ADDRESS = "serverLocalAddress"
|
||||
const val SERVER_INSECURE_CONCURRENCY = "serverInsecureConcurrency"
|
||||
|
||||
const val SERVER_UDP_RELAY_MODE = "serverUDPRelayMode"
|
||||
const val SERVER_CONGESTION_CONTROLLER = "serverCongestionController"
|
||||
const val SERVER_DISABLE_SNI = "serverDisableSNI"
|
||||
const val SERVER_REDUCE_RTT = "serverReduceRTT"
|
||||
const val SERVER_FAST_CONNECT = "serverFastConnect"
|
||||
|
||||
const val ROUTE_NAME = "routeName"
|
||||
const val ROUTE_DOMAIN = "routeDomain"
|
||||
const val ROUTE_IP = "routeIP"
|
||||
const val ROUTE_PORT = "routePort"
|
||||
const val ROUTE_SOURCE_PORT = "routeSourcePort"
|
||||
const val ROUTE_NETWORK = "routeNetwork"
|
||||
const val ROUTE_SOURCE = "routeSource"
|
||||
const val ROUTE_PROTOCOL = "routeProtocol"
|
||||
const val ROUTE_OUTBOUND = "routeOutbound"
|
||||
const val ROUTE_OUTBOUND_RULE = "routeOutboundRule"
|
||||
const val ROUTE_PACKAGES = "routePackages"
|
||||
|
||||
const val GROUP_NAME = "groupName"
|
||||
const val GROUP_TYPE = "groupType"
|
||||
const val GROUP_ORDER = "groupOrder"
|
||||
|
||||
const val GROUP_SUBSCRIPTION = "groupSubscription"
|
||||
const val SUBSCRIPTION_LINK = "subscriptionLink"
|
||||
const val SUBSCRIPTION_FORCE_RESOLVE = "subscriptionForceResolve"
|
||||
const val SUBSCRIPTION_DEDUPLICATION = "subscriptionDeduplication"
|
||||
const val SUBSCRIPTION_UPDATE = "subscriptionUpdate"
|
||||
const val SUBSCRIPTION_UPDATE_WHEN_CONNECTED_ONLY = "subscriptionUpdateWhenConnectedOnly"
|
||||
const val SUBSCRIPTION_USER_AGENT = "subscriptionUserAgent"
|
||||
const val SUBSCRIPTION_AUTO_UPDATE = "subscriptionAutoUpdate"
|
||||
const val SUBSCRIPTION_AUTO_UPDATE_DELAY = "subscriptionAutoUpdateDelay"
|
||||
|
||||
//
|
||||
|
||||
const val NEKO_PLUGIN_MANAGED = "nekoPlugins"
|
||||
const val APP_TLS_VERSION = "appTLSVersion"
|
||||
const val ENABLE_CLASH_API = "enableClashAPI"
|
||||
}
|
||||
|
||||
object TunImplementation {
|
||||
const val GVISOR = 0
|
||||
const val SYSTEM = 1
|
||||
}
|
||||
|
||||
object IPv6Mode {
|
||||
const val DISABLE = 0
|
||||
const val ENABLE = 1
|
||||
const val PREFER = 2
|
||||
const val ONLY = 3
|
||||
}
|
||||
|
||||
object GroupType {
|
||||
const val BASIC = 0
|
||||
const val SUBSCRIPTION = 1
|
||||
}
|
||||
|
||||
object GroupOrder {
|
||||
const val ORIGIN = 0
|
||||
const val BY_NAME = 1
|
||||
const val BY_DELAY = 2
|
||||
}
|
||||
|
||||
object Action {
|
||||
const val SERVICE = "io.nekohasekai.sagernet.SERVICE"
|
||||
const val CLOSE = "io.nekohasekai.sagernet.CLOSE"
|
||||
const val RELOAD = "io.nekohasekai.sagernet.RELOAD"
|
||||
const val SWITCH_WAKE_LOCK = "io.nekohasekai.sagernet.SWITCH_WAKELOCK"
|
||||
}
|
||||
@ -0,0 +1,88 @@
|
||||
/*******************************************************************************
|
||||
* *
|
||||
* Copyright (C) 2017 by Max Lv <max.c.lv@gmail.com> *
|
||||
* Copyright (C) 2017 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
|
||||
* *
|
||||
* This program is free software: you can redistribute it and/or modify *
|
||||
* it under the terms of the GNU General Public License as published by *
|
||||
* the Free Software Foundation, either version 3 of the License, or *
|
||||
* (at your option) any later version. *
|
||||
* *
|
||||
* This program is distributed in the hope that it will be useful, *
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||
* GNU General Public License for more details. *
|
||||
* *
|
||||
* You should have received a copy of the GNU General Public License *
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||
* *
|
||||
*******************************************************************************/
|
||||
|
||||
package io.nekohasekai.sagernet
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.content.pm.ShortcutManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.core.content.pm.ShortcutInfoCompat
|
||||
import androidx.core.content.pm.ShortcutManagerCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import io.nekohasekai.sagernet.aidl.ISagerNetService
|
||||
import io.nekohasekai.sagernet.bg.BaseService
|
||||
import io.nekohasekai.sagernet.bg.SagerConnection
|
||||
import io.nekohasekai.sagernet.database.DataStore
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
class QuickToggleShortcut : Activity(), SagerConnection.Callback {
|
||||
private val connection = SagerConnection()
|
||||
private var profileId = -1L
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
if (intent.action == Intent.ACTION_CREATE_SHORTCUT) {
|
||||
setResult(RESULT_OK, ShortcutManagerCompat.createShortcutResultIntent(this,
|
||||
ShortcutInfoCompat.Builder(this, "toggle")
|
||||
.setIntent(Intent(this,
|
||||
QuickToggleShortcut::class.java).setAction(Intent.ACTION_MAIN))
|
||||
.setIcon(IconCompat.createWithResource(this,
|
||||
R.drawable.ic_qu_shadowsocks_launcher))
|
||||
.setShortLabel(getString(R.string.quick_toggle))
|
||||
.build()))
|
||||
finish()
|
||||
} else {
|
||||
profileId = intent.getLongExtra("profile", -1L)
|
||||
connection.connect(this, this)
|
||||
if (Build.VERSION.SDK_INT >= 25) {
|
||||
getSystemService<ShortcutManager>()!!.reportShortcutUsed(if (profileId >= 0) "shortcut-profile-$profileId" else "toggle")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceConnected(service: ISagerNetService) {
|
||||
val state = BaseService.State.values()[service.state]
|
||||
when {
|
||||
state.canStop -> {
|
||||
if (profileId == DataStore.selectedProxy || profileId == -1L) {
|
||||
SagerNet.stopService()
|
||||
} else {
|
||||
DataStore.selectedProxy = profileId
|
||||
SagerNet.reloadService()
|
||||
}
|
||||
}
|
||||
state == BaseService.State.Stopped -> {
|
||||
if (profileId >= 0L) DataStore.selectedProxy = profileId
|
||||
SagerNet.startService()
|
||||
}
|
||||
}
|
||||
finish()
|
||||
}
|
||||
|
||||
override fun stateChanged(state: BaseService.State, profileName: String?, msg: String?) {}
|
||||
|
||||
override fun onDestroy() {
|
||||
connection.disconnect(this)
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
283
app/src/main/java/io/nekohasekai/sagernet/SagerNet.kt
Normal file
283
app/src/main/java/io/nekohasekai/sagernet/SagerNet.kt
Normal file
@ -0,0 +1,283 @@
|
||||
package io.nekohasekai.sagernet
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.*
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.res.Configuration
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.os.StrictMode
|
||||
import android.os.UserManager
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.getSystemService
|
||||
import go.Seq
|
||||
import io.nekohasekai.sagernet.bg.SagerConnection
|
||||
import io.nekohasekai.sagernet.database.DataStore
|
||||
import io.nekohasekai.sagernet.ktx.Logs
|
||||
import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher
|
||||
import io.nekohasekai.sagernet.ui.MainActivity
|
||||
import io.nekohasekai.sagernet.utils.*
|
||||
import kotlinx.coroutines.DEBUG_PROPERTY_NAME
|
||||
import kotlinx.coroutines.DEBUG_PROPERTY_VALUE_ON
|
||||
import libcore.BoxPlatformInterface
|
||||
import libcore.Libcore
|
||||
import libcore.NB4AInterface
|
||||
import moe.matsuri.nb4a.utils.JavaUtil
|
||||
import moe.matsuri.nb4a.utils.cleanWebview
|
||||
import java.net.InetSocketAddress
|
||||
import androidx.work.Configuration as WorkConfiguration
|
||||
|
||||
class SagerNet : Application(),
|
||||
BoxPlatformInterface,
|
||||
WorkConfiguration.Provider, NB4AInterface {
|
||||
|
||||
override fun attachBaseContext(base: Context) {
|
||||
super.attachBaseContext(base)
|
||||
|
||||
application = this
|
||||
}
|
||||
|
||||
val externalAssets by lazy { getExternalFilesDir(null) ?: filesDir }
|
||||
val process = JavaUtil.getProcessName()
|
||||
val isMainProcess = process == BuildConfig.APPLICATION_ID
|
||||
val isBgProcess = process.endsWith(":bg")
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
System.setProperty(DEBUG_PROPERTY_NAME, DEBUG_PROPERTY_VALUE_ON)
|
||||
Thread.setDefaultUncaughtExceptionHandler(CrashHandler)
|
||||
|
||||
if (isMainProcess || isBgProcess) {
|
||||
// fix multi process issue in Android 9+
|
||||
JavaUtil.handleWebviewDir(this)
|
||||
|
||||
runOnDefaultDispatcher {
|
||||
PackageCache.register()
|
||||
cleanWebview()
|
||||
}
|
||||
}
|
||||
|
||||
Seq.setContext(this)
|
||||
updateNotificationChannels()
|
||||
|
||||
// nb4a: init core
|
||||
externalAssets.mkdirs()
|
||||
Libcore.initCore(
|
||||
process,
|
||||
cacheDir.absolutePath + "/",
|
||||
filesDir.absolutePath + "/",
|
||||
externalAssets.absolutePath + "/",
|
||||
DataStore.logBufSize,
|
||||
DataStore.enableLog,
|
||||
this
|
||||
)
|
||||
|
||||
// libbox: platform interface
|
||||
Libcore.setBoxPlatformInterface(this)
|
||||
|
||||
if (isMainProcess) {
|
||||
Theme.apply(this)
|
||||
Theme.applyNightTheme()
|
||||
runOnDefaultDispatcher {
|
||||
DefaultNetworkListener.start(this) {
|
||||
underlyingNetwork = it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (BuildConfig.DEBUG) StrictMode.setVmPolicy(
|
||||
StrictMode.VmPolicy.Builder()
|
||||
.detectLeakedSqlLiteObjects()
|
||||
.detectLeakedClosableObjects()
|
||||
.detectLeakedRegistrationObjects()
|
||||
.penaltyLog()
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
fun getPackageInfo(packageName: String) = packageManager.getPackageInfo(
|
||||
packageName, if (Build.VERSION.SDK_INT >= 28) PackageManager.GET_SIGNING_CERTIFICATES
|
||||
else @Suppress("DEPRECATION") PackageManager.GET_SIGNATURES
|
||||
)!!
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
updateNotificationChannels()
|
||||
}
|
||||
|
||||
override fun getWorkManagerConfiguration(): WorkConfiguration {
|
||||
return WorkConfiguration.Builder()
|
||||
.setDefaultProcessName("${BuildConfig.APPLICATION_ID}:bg")
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun onTrimMemory(level: Int) {
|
||||
super.onTrimMemory(level)
|
||||
|
||||
Libcore.forceGc()
|
||||
}
|
||||
|
||||
@SuppressLint("InlinedApi")
|
||||
companion object {
|
||||
|
||||
lateinit var application: SagerNet
|
||||
|
||||
val isTv by lazy {
|
||||
uiMode.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION
|
||||
}
|
||||
|
||||
// /data/user_de available when not unlocked
|
||||
val deviceStorage by lazy {
|
||||
if (Build.VERSION.SDK_INT < 24) application else DeviceStorageApp(application)
|
||||
}
|
||||
|
||||
val configureIntent: (Context) -> PendingIntent by lazy {
|
||||
{
|
||||
PendingIntent.getActivity(
|
||||
it,
|
||||
0,
|
||||
Intent(
|
||||
application, MainActivity::class.java
|
||||
).setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT),
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0
|
||||
)
|
||||
}
|
||||
}
|
||||
val activity by lazy { application.getSystemService<ActivityManager>()!! }
|
||||
val clipboard by lazy { application.getSystemService<ClipboardManager>()!! }
|
||||
val connectivity by lazy { application.getSystemService<ConnectivityManager>()!! }
|
||||
val notification by lazy { application.getSystemService<NotificationManager>()!! }
|
||||
val user by lazy { application.getSystemService<UserManager>()!! }
|
||||
val uiMode by lazy { application.getSystemService<UiModeManager>()!! }
|
||||
val power by lazy { application.getSystemService<PowerManager>()!! }
|
||||
|
||||
val packageInfo: PackageInfo by lazy { application.getPackageInfo(application.packageName) }
|
||||
|
||||
fun getClipboardText(): String {
|
||||
return clipboard.primaryClip?.takeIf { it.itemCount > 0 }
|
||||
?.getItemAt(0)?.text?.toString() ?: ""
|
||||
}
|
||||
|
||||
fun trySetPrimaryClip(clip: String) = try {
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText(null, clip))
|
||||
true
|
||||
} catch (e: RuntimeException) {
|
||||
Logs.w(e)
|
||||
false
|
||||
}
|
||||
|
||||
fun updateNotificationChannels() {
|
||||
if (Build.VERSION.SDK_INT >= 26) @RequiresApi(26) {
|
||||
notification.createNotificationChannels(
|
||||
listOf(
|
||||
NotificationChannel(
|
||||
"service-vpn",
|
||||
application.getText(R.string.service_vpn),
|
||||
if (Build.VERSION.SDK_INT >= 28) NotificationManager.IMPORTANCE_MIN
|
||||
else NotificationManager.IMPORTANCE_LOW
|
||||
), // #1355
|
||||
NotificationChannel(
|
||||
"service-proxy",
|
||||
application.getText(R.string.service_proxy),
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
), NotificationChannel(
|
||||
"service-subscription",
|
||||
application.getText(R.string.service_subscription),
|
||||
NotificationManager.IMPORTANCE_DEFAULT
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun startService() = ContextCompat.startForegroundService(
|
||||
application, Intent(application, SagerConnection.serviceClass)
|
||||
)
|
||||
|
||||
fun reloadService() =
|
||||
application.sendBroadcast(Intent(Action.RELOAD).setPackage(application.packageName))
|
||||
|
||||
fun stopService() =
|
||||
application.sendBroadcast(Intent(Action.CLOSE).setPackage(application.packageName))
|
||||
|
||||
var underlyingNetwork: Network? = null
|
||||
|
||||
}
|
||||
|
||||
|
||||
// libbox interface
|
||||
|
||||
override fun autoDetectInterfaceControl(fd: Int) {
|
||||
DataStore.vpnService?.protect(fd)
|
||||
}
|
||||
|
||||
override fun openTun(singTunOptionsJson: String, tunPlatformOptionsJson: String): Long {
|
||||
if (DataStore.vpnService == null) {
|
||||
throw Exception("no VpnService")
|
||||
}
|
||||
return DataStore.vpnService!!.startVpn(singTunOptionsJson, tunPlatformOptionsJson).toLong()
|
||||
}
|
||||
|
||||
override fun useProcFS(): Boolean {
|
||||
return Build.VERSION.SDK_INT < Build.VERSION_CODES.Q
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
override fun findConnectionOwner(
|
||||
ipProto: Int, srcIp: String, srcPort: Int, destIp: String, destPort: Int
|
||||
): Int {
|
||||
return connectivity.getConnectionOwnerUid(
|
||||
ipProto, InetSocketAddress(srcIp, srcPort), InetSocketAddress(destIp, destPort)
|
||||
)
|
||||
}
|
||||
|
||||
override fun packageNameByUid(uid: Int): String {
|
||||
PackageCache.awaitLoadSync()
|
||||
|
||||
if (uid <= 1000L) {
|
||||
return "android"
|
||||
}
|
||||
|
||||
val packageNames = PackageCache.uidMap[uid]
|
||||
if (!packageNames.isNullOrEmpty()) for (packageName in packageNames) {
|
||||
return packageName
|
||||
}
|
||||
|
||||
error("unknown uid $uid")
|
||||
}
|
||||
|
||||
override fun uidByPackageName(packageName: String): Int {
|
||||
PackageCache.awaitLoadSync()
|
||||
return PackageCache[packageName] ?: 0
|
||||
}
|
||||
|
||||
// nb4a interface
|
||||
|
||||
override fun write(p: ByteArray): Long {
|
||||
// NB4AGuiLogWriter
|
||||
if (isBgProcess) {
|
||||
runOnDefaultDispatcher {
|
||||
DataStore.baseService?.data?.binder?.broadcast {
|
||||
it.cbLogUpdate(String(p))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
DataStore.postLogListener?.let { it(String(p)) }
|
||||
}
|
||||
return p.size.toLong()
|
||||
}
|
||||
|
||||
override fun useOfficialAssets(): Boolean {
|
||||
return DataStore.rulesProvider == 0
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
package io.nekohasekai.sagernet.aidl
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
data class SpeedDisplayData(
|
||||
// Bytes per second
|
||||
var txRateProxy: Long = 0L,
|
||||
var rxRateProxy: Long = 0L,
|
||||
var txRateDirect: Long = 0L,
|
||||
var rxRateDirect: Long = 0L,
|
||||
|
||||
// Bytes for the current session
|
||||
// Outbound "bypass" usage is not counted
|
||||
var txTotal: Long = 0L,
|
||||
var rxTotal: Long = 0L,
|
||||
) : Parcelable
|
||||
@ -0,0 +1,11 @@
|
||||
package io.nekohasekai.sagernet.aidl
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
data class TrafficData(
|
||||
var id: Long = 0L,
|
||||
var tx: Long = 0L,
|
||||
var rx: Long = 0L,
|
||||
) : Parcelable
|
||||
@ -0,0 +1,9 @@
|
||||
package io.nekohasekai.sagernet.bg
|
||||
|
||||
import java.io.Closeable
|
||||
|
||||
interface AbstractInstance : Closeable {
|
||||
|
||||
fun launch()
|
||||
|
||||
}
|
||||
345
app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt
Normal file
345
app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt
Normal file
@ -0,0 +1,345 @@
|
||||
package io.nekohasekai.sagernet.bg
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.*
|
||||
import android.widget.Toast
|
||||
import io.nekohasekai.sagernet.Action
|
||||
import io.nekohasekai.sagernet.R
|
||||
import io.nekohasekai.sagernet.SagerNet
|
||||
import io.nekohasekai.sagernet.aidl.ISagerNetService
|
||||
import io.nekohasekai.sagernet.aidl.ISagerNetServiceCallback
|
||||
import io.nekohasekai.sagernet.bg.proto.ProxyInstance
|
||||
import io.nekohasekai.sagernet.database.DataStore
|
||||
import io.nekohasekai.sagernet.database.SagerDatabase
|
||||
import io.nekohasekai.sagernet.ktx.*
|
||||
import io.nekohasekai.sagernet.plugin.PluginManager
|
||||
import io.nekohasekai.sagernet.utils.DefaultNetworkListener
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import libcore.Libcore
|
||||
import moe.matsuri.nb4a.Protocols
|
||||
import java.net.UnknownHostException
|
||||
|
||||
class BaseService {
|
||||
|
||||
enum class State(
|
||||
val canStop: Boolean = false,
|
||||
val started: Boolean = false,
|
||||
val connected: Boolean = false,
|
||||
) {
|
||||
/**
|
||||
* Idle state is only used by UI and will never be returned by BaseService.
|
||||
*/
|
||||
Idle, Connecting(true, true, false), Connected(true, true, true), Stopping, Stopped,
|
||||
}
|
||||
|
||||
interface ExpectedException
|
||||
|
||||
class Data internal constructor(private val service: Interface) {
|
||||
var state = State.Stopped
|
||||
var proxy: ProxyInstance? = null
|
||||
var notification: ServiceNotification? = null
|
||||
|
||||
val receiver = broadcastReceiver { _, intent ->
|
||||
when (intent.action) {
|
||||
Intent.ACTION_SHUTDOWN -> service.persistStats()
|
||||
Action.RELOAD -> service.forceLoad()
|
||||
Action.SWITCH_WAKE_LOCK -> runOnDefaultDispatcher { service.switchWakeLock() }
|
||||
else -> service.stopRunner()
|
||||
}
|
||||
}
|
||||
var closeReceiverRegistered = false
|
||||
|
||||
val binder = Binder(this)
|
||||
var connectingJob: Job? = null
|
||||
|
||||
fun changeState(s: State, msg: String? = null) {
|
||||
if (state == s && msg == null) return
|
||||
binder.stateChanged(s, msg)
|
||||
state = s
|
||||
}
|
||||
}
|
||||
|
||||
class Binder(private var data: Data? = null) : ISagerNetService.Stub(), CoroutineScope,
|
||||
AutoCloseable {
|
||||
private val callbacks = object : RemoteCallbackList<ISagerNetServiceCallback>() {
|
||||
override fun onCallbackDied(callback: ISagerNetServiceCallback?, cookie: Any?) {
|
||||
super.onCallbackDied(callback, cookie)
|
||||
}
|
||||
}
|
||||
|
||||
override val coroutineContext = Dispatchers.Main.immediate + Job()
|
||||
|
||||
override fun getState(): Int = (data?.state ?: State.Idle).ordinal
|
||||
override fun getProfileName(): String = data?.proxy?.profile?.displayName() ?: "Idle"
|
||||
|
||||
override fun registerCallback(cb: ISagerNetServiceCallback) {
|
||||
callbacks.register(cb)
|
||||
cb.updateWakeLockStatus(data?.proxy?.service?.wakeLock != null)
|
||||
}
|
||||
|
||||
val boardcastMutex = Mutex()
|
||||
|
||||
suspend fun broadcast(work: (ISagerNetServiceCallback) -> Unit) {
|
||||
boardcastMutex.withLock {
|
||||
val count = callbacks.beginBroadcast()
|
||||
try {
|
||||
repeat(count) {
|
||||
try {
|
||||
work(callbacks.getBroadcastItem(it))
|
||||
} catch (_: RemoteException) {
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
callbacks.finishBroadcast()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun unregisterCallback(cb: ISagerNetServiceCallback) {
|
||||
callbacks.unregister(cb)
|
||||
}
|
||||
|
||||
override fun urlTest(): Int {
|
||||
if (data?.proxy?.box == null) {
|
||||
error("core not started")
|
||||
}
|
||||
try {
|
||||
return Libcore.urlTest(
|
||||
data!!.proxy!!.box, DataStore.connectionTestURL, 3000
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
error(Protocols.genFriendlyMsg(e.readableMessage))
|
||||
}
|
||||
}
|
||||
|
||||
fun stateChanged(s: State, msg: String?) = launch {
|
||||
val profileName = profileName
|
||||
broadcast { it.stateChanged(s.ordinal, profileName, msg) }
|
||||
}
|
||||
|
||||
fun missingPlugin(pluginName: String) = launch {
|
||||
val profileName = profileName
|
||||
broadcast { it.missingPlugin(profileName, pluginName) }
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
callbacks.kill()
|
||||
cancel()
|
||||
data = null
|
||||
}
|
||||
}
|
||||
|
||||
interface Interface {
|
||||
val data: Data
|
||||
val tag: String
|
||||
fun createNotification(profileName: String): ServiceNotification
|
||||
|
||||
fun onBind(intent: Intent): IBinder? =
|
||||
if (intent.action == Action.SERVICE) data.binder else null
|
||||
|
||||
fun forceLoad() {
|
||||
if (DataStore.selectedProxy == 0L) {
|
||||
stopRunner(false, (this as Context).getString(R.string.profile_empty))
|
||||
}
|
||||
val s = data.state
|
||||
when {
|
||||
s == State.Stopped -> startRunner()
|
||||
s.canStop -> stopRunner(true)
|
||||
else -> Logs.w("Illegal state $s when invoking use")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun startProcesses() {
|
||||
data.proxy!!.launch()
|
||||
}
|
||||
|
||||
fun startRunner() {
|
||||
this as Context
|
||||
if (Build.VERSION.SDK_INT >= 26) startForegroundService(Intent(this, javaClass))
|
||||
else startService(Intent(this, javaClass))
|
||||
}
|
||||
|
||||
fun killProcesses() {
|
||||
data.proxy?.close()
|
||||
wakeLock?.apply {
|
||||
release()
|
||||
wakeLock = null
|
||||
}
|
||||
runOnDefaultDispatcher {
|
||||
DefaultNetworkListener.stop(this)
|
||||
}
|
||||
}
|
||||
|
||||
fun stopRunner(restart: Boolean = false, msg: String? = null) {
|
||||
DataStore.baseService = null
|
||||
DataStore.vpnService = null
|
||||
|
||||
if (data.state == State.Stopping) return
|
||||
data.notification?.destroy()
|
||||
data.notification = null
|
||||
this as Service
|
||||
|
||||
data.changeState(State.Stopping)
|
||||
|
||||
runOnMainDispatcher {
|
||||
data.connectingJob?.cancelAndJoin() // ensure stop connecting first
|
||||
// we use a coroutineScope here to allow clean-up in parallel
|
||||
coroutineScope {
|
||||
killProcesses()
|
||||
val data = data
|
||||
if (data.closeReceiverRegistered) {
|
||||
unregisterReceiver(data.receiver)
|
||||
data.closeReceiverRegistered = false
|
||||
}
|
||||
data.proxy = null
|
||||
}
|
||||
|
||||
// change the state
|
||||
data.changeState(State.Stopped, msg)
|
||||
// stop the service if nothing has bound to it
|
||||
if (restart) startRunner() else {
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
open fun persistStats() {
|
||||
// TODO NEW save app stats?
|
||||
}
|
||||
|
||||
// networks
|
||||
var upstreamInterfaceName: String?
|
||||
|
||||
suspend fun preInit() {
|
||||
DefaultNetworkListener.start(this) {
|
||||
SagerNet.connectivity.getLinkProperties(it)?.also { link ->
|
||||
val oldName = upstreamInterfaceName
|
||||
if (oldName != link.interfaceName) {
|
||||
upstreamInterfaceName = link.interfaceName
|
||||
}
|
||||
if (oldName != null && upstreamInterfaceName != null && oldName != upstreamInterfaceName) {
|
||||
Logs.d("Network changed: $oldName -> $upstreamInterfaceName")
|
||||
Libcore.resetAllConnections(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var wakeLock: PowerManager.WakeLock?
|
||||
fun acquireWakeLock()
|
||||
suspend fun switchWakeLock() {
|
||||
wakeLock?.apply {
|
||||
release()
|
||||
wakeLock = null
|
||||
data.binder.broadcast {
|
||||
it.updateWakeLockStatus(false)
|
||||
}
|
||||
} ?: apply {
|
||||
acquireWakeLock()
|
||||
data.binder.broadcast {
|
||||
it.updateWakeLockStatus(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun lateInit() {
|
||||
wakeLock?.apply {
|
||||
release()
|
||||
wakeLock = null
|
||||
}
|
||||
|
||||
if (DataStore.acquireWakeLock) {
|
||||
acquireWakeLock()
|
||||
data.binder.broadcast {
|
||||
it.updateWakeLockStatus(true)
|
||||
}
|
||||
} else {
|
||||
data.binder.broadcast {
|
||||
it.updateWakeLockStatus(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
DataStore.baseService = this
|
||||
|
||||
val data = data
|
||||
if (data.state != State.Stopped) return Service.START_NOT_STICKY
|
||||
val profile = SagerDatabase.proxyDao.getById(DataStore.selectedProxy)
|
||||
this as Context
|
||||
if (profile == null) { // gracefully shutdown: https://stackoverflow.com/q/47337857/2245107
|
||||
data.notification = createNotification("")
|
||||
stopRunner(false, getString(R.string.profile_empty))
|
||||
return Service.START_NOT_STICKY
|
||||
}
|
||||
|
||||
val proxy = ProxyInstance(profile, this)
|
||||
data.proxy = proxy
|
||||
if (!data.closeReceiverRegistered) {
|
||||
registerReceiver(data.receiver, IntentFilter().apply {
|
||||
addAction(Action.RELOAD)
|
||||
addAction(Intent.ACTION_SHUTDOWN)
|
||||
addAction(Action.CLOSE)
|
||||
addAction(Action.SWITCH_WAKE_LOCK)
|
||||
}, "$packageName.SERVICE", null)
|
||||
data.closeReceiverRegistered = true
|
||||
}
|
||||
|
||||
data.changeState(State.Connecting)
|
||||
runOnMainDispatcher {
|
||||
try {
|
||||
data.notification = createNotification(profile.displayName())
|
||||
|
||||
Executable.killAll() // clean up old processes
|
||||
preInit()
|
||||
proxy.init()
|
||||
DataStore.currentProfile = profile.id
|
||||
|
||||
proxy.processes = GuardedProcessPool {
|
||||
Logs.w(it)
|
||||
stopRunner(false, it.readableMessage)
|
||||
}
|
||||
|
||||
startProcesses()
|
||||
data.changeState(State.Connected)
|
||||
|
||||
for ((type, routeName) in proxy.config.alerts) {
|
||||
data.binder.broadcast {
|
||||
it.routeAlert(type, routeName)
|
||||
}
|
||||
}
|
||||
|
||||
lateInit()
|
||||
} catch (_: CancellationException) { // if the job was cancelled, it is canceller's responsibility to call stopRunner
|
||||
} catch (_: UnknownHostException) {
|
||||
stopRunner(false, getString(R.string.invalid_server))
|
||||
} catch (e: PluginManager.PluginNotFoundException) {
|
||||
Toast.makeText(this@Interface, e.readableMessage, Toast.LENGTH_SHORT).show()
|
||||
Logs.d(e.readableMessage)
|
||||
data.binder.missingPlugin(e.plugin)
|
||||
stopRunner(false, null)
|
||||
} catch (exc: Throwable) {
|
||||
if (exc.javaClass.name.endsWith("proxyerror")) {
|
||||
// error from golang
|
||||
Logs.w(exc.readableMessage)
|
||||
} else {
|
||||
Logs.w(exc)
|
||||
}
|
||||
stopRunner(
|
||||
false, "${getString(R.string.service_failed)}: ${exc.readableMessage}"
|
||||
)
|
||||
} finally {
|
||||
data.connectingJob = null
|
||||
}
|
||||
}
|
||||
return Service.START_NOT_STICKY
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
41
app/src/main/java/io/nekohasekai/sagernet/bg/Executable.kt
Normal file
41
app/src/main/java/io/nekohasekai/sagernet/bg/Executable.kt
Normal file
@ -0,0 +1,41 @@
|
||||
package io.nekohasekai.sagernet.bg
|
||||
|
||||
import android.system.ErrnoException
|
||||
import android.system.Os
|
||||
import android.system.OsConstants
|
||||
import android.text.TextUtils
|
||||
import io.nekohasekai.sagernet.ktx.Logs
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
object Executable {
|
||||
private val EXECUTABLES = setOf(
|
||||
"libtrojan.so",
|
||||
"libtrojan-go.so",
|
||||
"libnaive.so",
|
||||
"libhysteria.so",
|
||||
"libwg.so"
|
||||
)
|
||||
|
||||
fun killAll(alsoKillBg: Boolean = false) {
|
||||
for (process in File("/proc").listFiles { _, name -> TextUtils.isDigitsOnly(name) }
|
||||
?: return) {
|
||||
val exe = File(try {
|
||||
File(process, "cmdline").inputStream().bufferedReader().use {
|
||||
it.readText()
|
||||
}
|
||||
} catch (_: IOException) {
|
||||
continue
|
||||
}.split(Character.MIN_VALUE, limit = 2).first())
|
||||
if (EXECUTABLES.contains(exe.name) || (alsoKillBg && exe.name.endsWith(":bg"))) try {
|
||||
Os.kill(process.name.toInt(), OsConstants.SIGKILL)
|
||||
Logs.w("SIGKILL ${exe.name} (${process.name}) succeed")
|
||||
} catch (e: ErrnoException) {
|
||||
if (e.errno != OsConstants.ESRCH) {
|
||||
Logs.w("SIGKILL ${exe.absolutePath} (${process.name}) failed")
|
||||
Logs.w(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,121 @@
|
||||
package io.nekohasekai.sagernet.bg
|
||||
|
||||
import android.os.Build
|
||||
import android.os.SystemClock
|
||||
import android.system.ErrnoException
|
||||
import android.system.Os
|
||||
import android.system.OsConstants
|
||||
import androidx.annotation.MainThread
|
||||
import io.nekohasekai.sagernet.SagerNet
|
||||
import io.nekohasekai.sagernet.ktx.Logs
|
||||
import io.nekohasekai.sagernet.utils.Commandline
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import libcore.Libcore
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
class GuardedProcessPool(private val onFatal: suspend (IOException) -> Unit) : CoroutineScope {
|
||||
companion object {
|
||||
private val pid by lazy {
|
||||
Class.forName("java.lang.ProcessManager\$ProcessImpl").getDeclaredField("pid")
|
||||
.apply { isAccessible = true }
|
||||
}
|
||||
}
|
||||
|
||||
private inner class Guard(
|
||||
private val cmd: List<String>,
|
||||
private val env: Map<String, String> = mapOf()
|
||||
) {
|
||||
private lateinit var process: Process
|
||||
|
||||
private fun streamLogger(input: InputStream, logger: (String) -> Unit) = try {
|
||||
input.bufferedReader().forEachLine(logger)
|
||||
} catch (_: IOException) {
|
||||
} // ignore
|
||||
|
||||
fun start() {
|
||||
process = ProcessBuilder(cmd).directory(SagerNet.application.noBackupFilesDir).apply {
|
||||
environment().putAll(env)
|
||||
}.start()
|
||||
}
|
||||
|
||||
@DelicateCoroutinesApi
|
||||
suspend fun looper(onRestartCallback: (suspend () -> Unit)?) {
|
||||
var running = true
|
||||
val cmdName = File(cmd.first()).nameWithoutExtension
|
||||
val exitChannel = Channel<Int>()
|
||||
try {
|
||||
while (true) {
|
||||
thread(name = "stderr-$cmdName") {
|
||||
streamLogger(process.errorStream) { Libcore.nekoLogPrintln("[$cmdName] $it") }
|
||||
}
|
||||
thread(name = "stdout-$cmdName") {
|
||||
streamLogger(process.inputStream) { Libcore.nekoLogPrintln("[$cmdName] $it") }
|
||||
// this thread also acts as a daemon thread for waitFor
|
||||
runBlocking { exitChannel.send(process.waitFor()) }
|
||||
}
|
||||
val startTime = SystemClock.elapsedRealtime()
|
||||
val exitCode = exitChannel.receive()
|
||||
running = false
|
||||
when {
|
||||
SystemClock.elapsedRealtime() - startTime < 1000 -> throw IOException(
|
||||
"$cmdName exits too fast (exit code: $exitCode)"
|
||||
)
|
||||
exitCode == 128 + OsConstants.SIGKILL -> Logs.w("$cmdName was killed")
|
||||
else -> Logs.w(IOException("$cmdName unexpectedly exits with code $exitCode"))
|
||||
}
|
||||
Logs.i("restart process: ${Commandline.toString(cmd)} (last exit code: $exitCode)")
|
||||
start()
|
||||
running = true
|
||||
onRestartCallback?.invoke()
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Logs.w("error occurred. stop guard: ${Commandline.toString(cmd)}")
|
||||
GlobalScope.launch(Dispatchers.Main) { onFatal(e) }
|
||||
} finally {
|
||||
if (running) withContext(NonCancellable) { // clean-up cannot be cancelled
|
||||
if (Build.VERSION.SDK_INT < 24) {
|
||||
try {
|
||||
Os.kill(pid.get(process) as Int, OsConstants.SIGTERM)
|
||||
} catch (e: ErrnoException) {
|
||||
if (e.errno != OsConstants.ESRCH) Logs.w(e)
|
||||
} catch (e: ReflectiveOperationException) {
|
||||
Logs.w(e)
|
||||
}
|
||||
if (withTimeoutOrNull(500) { exitChannel.receive() } != null) return@withContext
|
||||
}
|
||||
process.destroy() // kill the process
|
||||
if (Build.VERSION.SDK_INT >= 26) {
|
||||
if (withTimeoutOrNull(1000) { exitChannel.receive() } != null) return@withContext
|
||||
process.destroyForcibly() // Force to kill the process if it's still alive
|
||||
}
|
||||
exitChannel.receive()
|
||||
} // otherwise process already exited, nothing to be done
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val coroutineContext = Dispatchers.Main.immediate + Job()
|
||||
|
||||
@MainThread
|
||||
fun start(
|
||||
cmd: List<String>,
|
||||
env: MutableMap<String, String> = mutableMapOf(),
|
||||
onRestartCallback: (suspend () -> Unit)? = null
|
||||
) {
|
||||
Logs.i("start process: ${Commandline.toString(cmd)}")
|
||||
Guard(cmd, env).apply {
|
||||
start() // if start fails, IOException will be thrown directly
|
||||
launch { looper(onRestartCallback) }
|
||||
}
|
||||
}
|
||||
|
||||
@MainThread
|
||||
fun close(scope: CoroutineScope) {
|
||||
cancel()
|
||||
coroutineContext[Job]!!.also { job -> scope.launch { job.cancelAndJoin() } }
|
||||
}
|
||||
}
|
||||
27
app/src/main/java/io/nekohasekai/sagernet/bg/ProxyService.kt
Normal file
27
app/src/main/java/io/nekohasekai/sagernet/bg/ProxyService.kt
Normal file
@ -0,0 +1,27 @@
|
||||
package io.nekohasekai.sagernet.bg
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.PowerManager
|
||||
import io.nekohasekai.sagernet.SagerNet
|
||||
|
||||
class ProxyService : Service(), BaseService.Interface {
|
||||
override val data = BaseService.Data(this)
|
||||
override val tag: String get() = "SagerNetProxyService"
|
||||
override fun createNotification(profileName: String): ServiceNotification =
|
||||
ServiceNotification(this, profileName, "service-proxy", true)
|
||||
|
||||
override var wakeLock: PowerManager.WakeLock? = null
|
||||
override var upstreamInterfaceName: String? = null
|
||||
|
||||
@SuppressLint("WakelockTimeout")
|
||||
override fun acquireWakeLock() {
|
||||
wakeLock = SagerNet.power.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "sagernet:proxy")
|
||||
.apply { acquire() }
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent) = super.onBind(intent)
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int =
|
||||
super<BaseService.Interface>.onStartCommand(intent, flags, startId)
|
||||
}
|
||||
167
app/src/main/java/io/nekohasekai/sagernet/bg/SagerConnection.kt
Normal file
167
app/src/main/java/io/nekohasekai/sagernet/bg/SagerConnection.kt
Normal file
@ -0,0 +1,167 @@
|
||||
package io.nekohasekai.sagernet.bg
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.os.IBinder
|
||||
import android.os.RemoteException
|
||||
import io.nekohasekai.sagernet.Action
|
||||
import io.nekohasekai.sagernet.Key
|
||||
import io.nekohasekai.sagernet.aidl.*
|
||||
import io.nekohasekai.sagernet.database.DataStore
|
||||
import io.nekohasekai.sagernet.ktx.runOnMainDispatcher
|
||||
|
||||
class SagerConnection(private var listenForDeath: Boolean = false) : ServiceConnection,
|
||||
IBinder.DeathRecipient {
|
||||
|
||||
companion object {
|
||||
val serviceClass
|
||||
get() = when (DataStore.serviceMode) {
|
||||
Key.MODE_PROXY -> ProxyService::class
|
||||
Key.MODE_VPN -> VpnService::class // Key.MODE_TRANS -> TransproxyService::class
|
||||
else -> throw UnknownError()
|
||||
}.java
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
// smaller ISagerNetServiceCallback
|
||||
|
||||
fun cbSpeedUpdate(stats: SpeedDisplayData) {}
|
||||
fun cbTrafficUpdate(data: TrafficData) {}
|
||||
|
||||
fun stateChanged(state: BaseService.State, profileName: String?, msg: String?)
|
||||
|
||||
fun missingPlugin(profileName: String, pluginName: String) {}
|
||||
fun routeAlert(type: Int, routeName: String) {}
|
||||
|
||||
fun onServiceConnected(service: ISagerNetService)
|
||||
|
||||
/**
|
||||
* Different from Android framework, this method will be called even when you call `detachService`.
|
||||
*/
|
||||
fun onServiceDisconnected() {}
|
||||
fun onBinderDied() {}
|
||||
}
|
||||
|
||||
private var connectionActive = false
|
||||
private var callbackRegistered = false
|
||||
private var callback: Callback? = null
|
||||
private val serviceCallback = object : ISagerNetServiceCallback.Stub() {
|
||||
|
||||
override fun stateChanged(state: Int, profileName: String?, msg: String?) {
|
||||
val s = BaseService.State.values()[state]
|
||||
DataStore.serviceState = s
|
||||
val callback = callback ?: return
|
||||
runOnMainDispatcher {
|
||||
callback.stateChanged(s, profileName, msg)
|
||||
}
|
||||
}
|
||||
|
||||
override fun cbSpeedUpdate(stats: SpeedDisplayData) {
|
||||
val callback = callback ?: return
|
||||
runOnMainDispatcher {
|
||||
callback.cbSpeedUpdate(stats)
|
||||
}
|
||||
}
|
||||
|
||||
override fun cbTrafficUpdate(stats: TrafficData) {
|
||||
val callback = callback ?: return
|
||||
runOnMainDispatcher {
|
||||
callback.cbTrafficUpdate(stats)
|
||||
}
|
||||
}
|
||||
|
||||
override fun missingPlugin(profileName: String, pluginName: String) {
|
||||
val callback = callback ?: return
|
||||
runOnMainDispatcher {
|
||||
callback.missingPlugin(profileName, pluginName)
|
||||
}
|
||||
}
|
||||
|
||||
override fun routeAlert(type: Int, routeName: String) {
|
||||
val callback = callback ?: return
|
||||
runOnMainDispatcher {
|
||||
callback.routeAlert(type, routeName)
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateWakeLockStatus(acquired: Boolean) {
|
||||
}
|
||||
|
||||
override fun cbLogUpdate(str: String?) {
|
||||
DataStore.postLogListener?.let {
|
||||
if (str != null) {
|
||||
it(str)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private var binder: IBinder? = null
|
||||
|
||||
var service: ISagerNetService? = null
|
||||
|
||||
override fun onServiceConnected(name: ComponentName?, binder: IBinder) {
|
||||
this.binder = binder
|
||||
val service = ISagerNetService.Stub.asInterface(binder)!!
|
||||
this.service = service
|
||||
try {
|
||||
if (listenForDeath) binder.linkToDeath(this, 0)
|
||||
check(!callbackRegistered)
|
||||
service.registerCallback(serviceCallback)
|
||||
callbackRegistered = true
|
||||
} catch (e: RemoteException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
callback!!.onServiceConnected(service)
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
unregisterCallback()
|
||||
callback?.onServiceDisconnected()
|
||||
service = null
|
||||
binder = null
|
||||
}
|
||||
|
||||
override fun binderDied() {
|
||||
service = null
|
||||
callbackRegistered = false
|
||||
callback?.also { runOnMainDispatcher { it.onBinderDied() } }
|
||||
}
|
||||
|
||||
private fun unregisterCallback() {
|
||||
val service = service
|
||||
if (service != null && callbackRegistered) try {
|
||||
service.unregisterCallback(serviceCallback)
|
||||
} catch (_: RemoteException) {
|
||||
}
|
||||
callbackRegistered = false
|
||||
}
|
||||
|
||||
fun connect(context: Context, callback: Callback) {
|
||||
if (connectionActive) return
|
||||
connectionActive = true
|
||||
check(this.callback == null)
|
||||
this.callback = callback
|
||||
val intent = Intent(context, serviceClass).setAction(Action.SERVICE)
|
||||
context.bindService(intent, this, Context.BIND_AUTO_CREATE)
|
||||
}
|
||||
|
||||
fun disconnect(context: Context) {
|
||||
unregisterCallback()
|
||||
if (connectionActive) try {
|
||||
context.unbindService(this)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
} // ignore
|
||||
connectionActive = false
|
||||
if (listenForDeath) try {
|
||||
binder?.unlinkToDeath(this, 0)
|
||||
} catch (_: NoSuchElementException) {
|
||||
}
|
||||
binder = null
|
||||
service = null
|
||||
callback = null
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,202 @@
|
||||
package io.nekohasekai.sagernet.bg
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.Build
|
||||
import android.text.format.Formatter
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import io.nekohasekai.sagernet.Action
|
||||
import io.nekohasekai.sagernet.R
|
||||
import io.nekohasekai.sagernet.SagerNet
|
||||
import io.nekohasekai.sagernet.aidl.ISagerNetServiceCallback
|
||||
import io.nekohasekai.sagernet.aidl.SpeedDisplayData
|
||||
import io.nekohasekai.sagernet.aidl.TrafficData
|
||||
import io.nekohasekai.sagernet.database.DataStore
|
||||
import io.nekohasekai.sagernet.ktx.app
|
||||
import io.nekohasekai.sagernet.ktx.getColorAttr
|
||||
import io.nekohasekai.sagernet.ui.SwitchActivity
|
||||
import io.nekohasekai.sagernet.utils.Theme
|
||||
|
||||
/**
|
||||
* User can customize visibility of notification since Android 8.
|
||||
* The default visibility:
|
||||
*
|
||||
* Android 8.x: always visible due to system limitations
|
||||
* VPN: always invisible because of VPN notification/icon
|
||||
* Other: always visible
|
||||
*
|
||||
* See also: https://github.com/aosp-mirror/platform_frameworks_base/commit/070d142993403cc2c42eca808ff3fafcee220ac4
|
||||
*/
|
||||
class ServiceNotification(
|
||||
private val service: BaseService.Interface, profileName: String,
|
||||
channel: String, visible: Boolean = false,
|
||||
) : BroadcastReceiver() {
|
||||
companion object {
|
||||
const val notificationId = 1
|
||||
val flags =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0
|
||||
}
|
||||
|
||||
val trafficStatistics = DataStore.profileTrafficStatistics
|
||||
val showDirectSpeed = DataStore.showDirectSpeed
|
||||
|
||||
private val callback: ISagerNetServiceCallback by lazy {
|
||||
object : ISagerNetServiceCallback.Stub() {
|
||||
override fun cbSpeedUpdate(stats: SpeedDisplayData) {
|
||||
if (!trafficStatistics) return
|
||||
builder.apply {
|
||||
if (showDirectSpeed) {
|
||||
val speedDetail = (service as Context).getString(
|
||||
R.string.speed_detail, service.getString(
|
||||
R.string.speed, Formatter.formatFileSize(service, stats.txRateProxy)
|
||||
), service.getString(
|
||||
R.string.speed, Formatter.formatFileSize(service, stats.rxRateProxy)
|
||||
), service.getString(
|
||||
R.string.speed,
|
||||
Formatter.formatFileSize(service, stats.txRateDirect)
|
||||
), service.getString(
|
||||
R.string.speed,
|
||||
Formatter.formatFileSize(service, stats.rxRateDirect)
|
||||
)
|
||||
)
|
||||
setStyle(NotificationCompat.BigTextStyle().bigText(speedDetail))
|
||||
setContentText(speedDetail)
|
||||
} else {
|
||||
val speedSimple = (service as Context).getString(
|
||||
R.string.traffic, service.getString(
|
||||
R.string.speed, Formatter.formatFileSize(service, stats.txRateProxy)
|
||||
), service.getString(
|
||||
R.string.speed, Formatter.formatFileSize(service, stats.rxRateProxy)
|
||||
)
|
||||
)
|
||||
setContentText(speedSimple)
|
||||
}
|
||||
setSubText(
|
||||
service.getString(
|
||||
R.string.traffic,
|
||||
Formatter.formatFileSize(service, stats.txTotal),
|
||||
Formatter.formatFileSize(service, stats.rxTotal)
|
||||
)
|
||||
)
|
||||
}
|
||||
update()
|
||||
}
|
||||
|
||||
override fun cbTrafficUpdate(stats: TrafficData?) {
|
||||
}
|
||||
|
||||
override fun stateChanged(state: Int, profileName: String?, msg: String?) {
|
||||
}
|
||||
|
||||
override fun missingPlugin(profileName: String?, pluginName: String?) {
|
||||
}
|
||||
|
||||
override fun routeAlert(type: Int, routeName: String?) {
|
||||
}
|
||||
|
||||
override fun updateWakeLockStatus(acquired: Boolean) {
|
||||
updateActions(acquired)
|
||||
builder.priority =
|
||||
if (acquired) NotificationCompat.PRIORITY_HIGH else NotificationCompat.PRIORITY_LOW
|
||||
update()
|
||||
}
|
||||
|
||||
override fun cbLogUpdate(str: String?) {
|
||||
}
|
||||
}
|
||||
}
|
||||
private var callbackRegistered = false
|
||||
|
||||
private val builder = NotificationCompat.Builder(service as Context, channel)
|
||||
.setWhen(0)
|
||||
.setTicker(service.getString(R.string.forward_success))
|
||||
.setContentTitle(profileName)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setContentIntent(SagerNet.configureIntent(service))
|
||||
.setSmallIcon(R.drawable.ic_service_active)
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
.setPriority(if (visible) NotificationCompat.PRIORITY_LOW else NotificationCompat.PRIORITY_MIN)
|
||||
|
||||
init {
|
||||
service as Context
|
||||
updateActions(false)
|
||||
|
||||
Theme.apply(app)
|
||||
Theme.apply(service)
|
||||
builder.color = service.getColorAttr(R.attr.colorPrimary)
|
||||
|
||||
updateCallback(SagerNet.power.isInteractive)
|
||||
service.registerReceiver(this, IntentFilter().apply {
|
||||
addAction(Intent.ACTION_SCREEN_ON)
|
||||
addAction(Intent.ACTION_SCREEN_OFF)
|
||||
})
|
||||
show()
|
||||
}
|
||||
|
||||
fun updateActions(wakeLockAcquired: Boolean) {
|
||||
service as Context
|
||||
|
||||
builder.clearActions()
|
||||
val closeAction = NotificationCompat.Action.Builder(
|
||||
0, service.getText(R.string.stop), PendingIntent.getBroadcast(
|
||||
service, 0, Intent(Action.CLOSE).setPackage(service.packageName), flags
|
||||
)
|
||||
).apply {
|
||||
setShowsUserInterface(false)
|
||||
}.build()
|
||||
builder.addAction(closeAction)
|
||||
|
||||
val switchAction = NotificationCompat.Action.Builder(
|
||||
0, service.getString(R.string.action_switch), PendingIntent.getActivity(
|
||||
service, 0, Intent(service, SwitchActivity::class.java), flags
|
||||
)
|
||||
).apply {
|
||||
setShowsUserInterface(false)
|
||||
}.build()
|
||||
builder.addAction(switchAction)
|
||||
|
||||
val wakeLockAction = NotificationCompat.Action.Builder(
|
||||
0,
|
||||
service.getText(if (!wakeLockAcquired) R.string.acquire_wake_lock else R.string.release_wake_lock),
|
||||
PendingIntent.getBroadcast(
|
||||
service,
|
||||
0,
|
||||
Intent(Action.SWITCH_WAKE_LOCK).setPackage(service.packageName),
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0
|
||||
)
|
||||
).apply {
|
||||
setShowsUserInterface(false)
|
||||
}.build()
|
||||
builder.addAction(wakeLockAction)
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (service.data.state == BaseService.State.Connected) updateCallback(intent.action == Intent.ACTION_SCREEN_ON)
|
||||
}
|
||||
|
||||
private fun updateCallback(screenOn: Boolean) {
|
||||
if (!trafficStatistics) return
|
||||
if (screenOn) {
|
||||
service.data.binder.registerCallback(callback)
|
||||
callbackRegistered = true
|
||||
} else if (callbackRegistered) { // unregister callback to save battery
|
||||
service.data.binder.unregisterCallback(callback)
|
||||
callbackRegistered = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun show() = (service as Service).startForeground(notificationId, builder.build())
|
||||
private fun update() =
|
||||
NotificationManagerCompat.from(service as Service).notify(notificationId, builder.build())
|
||||
|
||||
fun destroy() {
|
||||
(service as Service).stopForeground(true)
|
||||
service.unregisterReceiver(this)
|
||||
updateCallback(false)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,97 @@
|
||||
package io.nekohasekai.sagernet.bg
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.PeriodicWorkRequest
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.multiprocess.RemoteWorkManager
|
||||
import io.nekohasekai.sagernet.R
|
||||
import io.nekohasekai.sagernet.database.DataStore
|
||||
import io.nekohasekai.sagernet.database.SagerDatabase
|
||||
import io.nekohasekai.sagernet.group.GroupUpdater
|
||||
import io.nekohasekai.sagernet.ktx.Logs
|
||||
import io.nekohasekai.sagernet.ktx.app
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object SubscriptionUpdater {
|
||||
|
||||
private const val WORK_NAME = "SubscriptionUpdater"
|
||||
|
||||
suspend fun reconfigureUpdater() {
|
||||
RemoteWorkManager.getInstance(app).cancelUniqueWork(WORK_NAME)
|
||||
|
||||
val subscriptions = SagerDatabase.groupDao.subscriptions()
|
||||
.filter { it.subscription!!.autoUpdate }
|
||||
if (subscriptions.isEmpty()) return
|
||||
|
||||
// PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS
|
||||
var minDelay =
|
||||
subscriptions.minByOrNull { it.subscription!!.autoUpdateDelay }!!.subscription!!.autoUpdateDelay.toLong()
|
||||
val now = System.currentTimeMillis() / 1000L
|
||||
var minInitDelay =
|
||||
subscriptions.minOf { now - it.subscription!!.lastUpdated - (minDelay * 60) }
|
||||
if (minDelay < 15) minDelay = 15
|
||||
if (minInitDelay > 60) minInitDelay = 60
|
||||
|
||||
// main process
|
||||
RemoteWorkManager.getInstance(app).enqueueUniquePeriodicWork(
|
||||
WORK_NAME,
|
||||
ExistingPeriodicWorkPolicy.REPLACE,
|
||||
PeriodicWorkRequest.Builder(UpdateTask::class.java, minDelay, TimeUnit.MINUTES)
|
||||
.apply {
|
||||
if (minInitDelay > 0) setInitialDelay(minInitDelay, TimeUnit.SECONDS)
|
||||
}
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
class UpdateTask(
|
||||
appContext: Context, params: WorkerParameters
|
||||
) : CoroutineWorker(appContext, params) {
|
||||
|
||||
val nm = NotificationManagerCompat.from(applicationContext)
|
||||
|
||||
val notification = NotificationCompat.Builder(applicationContext, "service-subscription")
|
||||
.setWhen(0)
|
||||
.setTicker(applicationContext.getString(R.string.forward_success))
|
||||
.setContentTitle(applicationContext.getString(R.string.subscription_update))
|
||||
.setSmallIcon(R.drawable.ic_service_active)
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
var subscriptions =
|
||||
SagerDatabase.groupDao.subscriptions().filter { it.subscription!!.autoUpdate }
|
||||
if (!DataStore.serviceState.connected) {
|
||||
Logs.d("work: not connected")
|
||||
subscriptions = subscriptions.filter { !it.subscription!!.updateWhenConnectedOnly }
|
||||
}
|
||||
|
||||
if (subscriptions.isNotEmpty()) for (profile in subscriptions) {
|
||||
val subscription = profile.subscription!!
|
||||
|
||||
if (((System.currentTimeMillis() / 1000).toInt() - subscription.lastUpdated) < subscription.autoUpdateDelay * 60) {
|
||||
Logs.d("work: not updating " + profile.displayName())
|
||||
continue
|
||||
}
|
||||
Logs.d("work: updating " + profile.displayName())
|
||||
|
||||
notification.setContentText(
|
||||
applicationContext.getString(
|
||||
R.string.subscription_update_message, profile.displayName()
|
||||
)
|
||||
)
|
||||
nm.notify(2, notification.build())
|
||||
|
||||
GroupUpdater.executeUpdate(profile, false)
|
||||
}
|
||||
|
||||
nm.cancel(2)
|
||||
|
||||
return Result.success()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
104
app/src/main/java/io/nekohasekai/sagernet/bg/TileService.kt
Normal file
104
app/src/main/java/io/nekohasekai/sagernet/bg/TileService.kt
Normal file
@ -0,0 +1,104 @@
|
||||
/*******************************************************************************
|
||||
* *
|
||||
* Copyright (C) 2017 by Max Lv <max.c.lv@gmail.com> *
|
||||
* Copyright (C) 2017 by Mygod Studio <contact-shadowsocks-android@mygod.be> *
|
||||
* *
|
||||
* This program is free software: you can redistribute it and/or modify *
|
||||
* it under the terms of the GNU General Public License as published by *
|
||||
* the Free Software Foundation, either version 3 of the License, or *
|
||||
* (at your option) any later version. *
|
||||
* *
|
||||
* This program is distributed in the hope that it will be useful, *
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||
* GNU General Public License for more details. *
|
||||
* *
|
||||
* You should have received a copy of the GNU General Public License *
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||
* *
|
||||
*******************************************************************************/
|
||||
|
||||
package io.nekohasekai.sagernet.bg
|
||||
|
||||
import android.graphics.drawable.Icon
|
||||
import android.service.quicksettings.Tile
|
||||
import androidx.annotation.RequiresApi
|
||||
import io.nekohasekai.sagernet.R
|
||||
import io.nekohasekai.sagernet.SagerNet
|
||||
import io.nekohasekai.sagernet.aidl.ISagerNetService
|
||||
import android.service.quicksettings.TileService as BaseTileService
|
||||
|
||||
@RequiresApi(24)
|
||||
class TileService : BaseTileService(), SagerConnection.Callback {
|
||||
private val iconIdle by lazy { Icon.createWithResource(this, R.drawable.ic_service_idle) }
|
||||
private val iconBusy by lazy { Icon.createWithResource(this, R.drawable.ic_service_busy) }
|
||||
private val iconConnected by lazy {
|
||||
Icon.createWithResource(this, R.drawable.ic_service_active)
|
||||
}
|
||||
private var tapPending = false
|
||||
|
||||
private val connection = SagerConnection()
|
||||
override fun stateChanged(state: BaseService.State, profileName: String?, msg: String?) =
|
||||
updateTile(state) { profileName }
|
||||
|
||||
override fun onServiceConnected(service: ISagerNetService) {
|
||||
updateTile(BaseService.State.values()[service.state]) { service.profileName }
|
||||
if (tapPending) {
|
||||
tapPending = false
|
||||
onClick()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartListening() {
|
||||
super.onStartListening()
|
||||
connection.connect(this, this)
|
||||
}
|
||||
|
||||
override fun onStopListening() {
|
||||
connection.disconnect(this)
|
||||
super.onStopListening()
|
||||
}
|
||||
|
||||
override fun onClick() {
|
||||
toggle()
|
||||
}
|
||||
|
||||
private fun updateTile(serviceState: BaseService.State, profileName: () -> String?) {
|
||||
qsTile?.apply {
|
||||
label = null
|
||||
when (serviceState) {
|
||||
BaseService.State.Idle -> error("serviceState")
|
||||
BaseService.State.Connecting -> {
|
||||
icon = iconBusy
|
||||
state = Tile.STATE_ACTIVE
|
||||
}
|
||||
BaseService.State.Connected -> {
|
||||
icon = iconConnected
|
||||
label = profileName()
|
||||
state = Tile.STATE_ACTIVE
|
||||
}
|
||||
BaseService.State.Stopping -> {
|
||||
icon = iconBusy
|
||||
state = Tile.STATE_UNAVAILABLE
|
||||
}
|
||||
BaseService.State.Stopped -> {
|
||||
icon = iconIdle
|
||||
state = Tile.STATE_INACTIVE
|
||||
}
|
||||
}
|
||||
label = label ?: getString(R.string.app_name)
|
||||
updateTile()
|
||||
}
|
||||
}
|
||||
|
||||
private fun toggle() {
|
||||
val service = connection.service
|
||||
if (service == null) tapPending =
|
||||
true else BaseService.State.values()[service.state].let { state ->
|
||||
when {
|
||||
state.canStop -> SagerNet.stopService()
|
||||
state == BaseService.State.Stopped -> SagerNet.startService()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
269
app/src/main/java/io/nekohasekai/sagernet/bg/VpnService.kt
Normal file
269
app/src/main/java/io/nekohasekai/sagernet/bg/VpnService.kt
Normal file
@ -0,0 +1,269 @@
|
||||
package io.nekohasekai.sagernet.bg
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.ProxyInfo
|
||||
import android.os.Build
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.os.PowerManager
|
||||
import io.nekohasekai.sagernet.*
|
||||
import io.nekohasekai.sagernet.database.DataStore
|
||||
import io.nekohasekai.sagernet.fmt.LOCALHOST
|
||||
import io.nekohasekai.sagernet.fmt.hysteria.HysteriaBean
|
||||
import io.nekohasekai.sagernet.ktx.*
|
||||
import io.nekohasekai.sagernet.ui.VpnRequestActivity
|
||||
import io.nekohasekai.sagernet.utils.Subnet
|
||||
import libcore.*
|
||||
import moe.matsuri.nb4a.net.LocalResolverImpl
|
||||
import moe.matsuri.nb4a.proxy.neko.needBypassRootUid
|
||||
import android.net.VpnService as BaseVpnService
|
||||
|
||||
class VpnService : BaseVpnService(),
|
||||
BaseService.Interface {
|
||||
|
||||
companion object {
|
||||
|
||||
const val PRIVATE_VLAN4_CLIENT = "172.19.0.1"
|
||||
const val PRIVATE_VLAN4_ROUTER = "172.19.0.2"
|
||||
const val FAKEDNS_VLAN4_CLIENT = "198.18.0.0"
|
||||
const val PRIVATE_VLAN6_CLIENT = "fdfe:dcba:9876::1"
|
||||
const val PRIVATE_VLAN6_ROUTER = "fdfe:dcba:9876::2"
|
||||
|
||||
}
|
||||
|
||||
var conn: ParcelFileDescriptor? = null
|
||||
|
||||
private var metered = false
|
||||
|
||||
override var upstreamInterfaceName: String? = null
|
||||
|
||||
override suspend fun startProcesses() {
|
||||
DataStore.vpnService = this
|
||||
super.startProcesses() // launch proxy instance
|
||||
}
|
||||
|
||||
override var wakeLock: PowerManager.WakeLock? = null
|
||||
|
||||
@SuppressLint("WakelockTimeout")
|
||||
override fun acquireWakeLock() {
|
||||
wakeLock = SagerNet.power.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "sagernet:vpn")
|
||||
.apply { acquire() }
|
||||
}
|
||||
|
||||
@Suppress("EXPERIMENTAL_API_USAGE")
|
||||
override fun killProcesses() {
|
||||
conn?.close()
|
||||
conn = null
|
||||
super.killProcesses()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent) = when (intent.action) {
|
||||
SERVICE_INTERFACE -> super<BaseVpnService>.onBind(intent)
|
||||
else -> super<BaseService.Interface>.onBind(intent)
|
||||
}
|
||||
|
||||
override val data = BaseService.Data(this)
|
||||
override val tag = "SagerNetVpnService"
|
||||
override fun createNotification(profileName: String) =
|
||||
ServiceNotification(this, profileName, "service-vpn")
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (DataStore.serviceMode == Key.MODE_VPN) {
|
||||
if (prepare(this) != null) {
|
||||
startActivity(
|
||||
Intent(
|
||||
this, VpnRequestActivity::class.java
|
||||
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
)
|
||||
} else return super<BaseService.Interface>.onStartCommand(intent, flags, startId)
|
||||
}
|
||||
stopRunner()
|
||||
return Service.START_NOT_STICKY
|
||||
}
|
||||
|
||||
inner class NullConnectionException : NullPointerException(),
|
||||
BaseService.ExpectedException {
|
||||
override fun getLocalizedMessage() = getString(R.string.reboot_required)
|
||||
}
|
||||
|
||||
fun startVpn(tunOptionsJson: String, tunPlatformOptionsJson: String): Int {
|
||||
// Logs.d(tunOptionsJson)
|
||||
// Logs.d(tunPlatformOptionsJson)
|
||||
// val tunOptions = JSONObject(tunOptionsJson)
|
||||
|
||||
// address & route & MTU ...... use NB4A GUI config
|
||||
val profile = data.proxy!!.profile
|
||||
val builder = Builder().setConfigureIntent(SagerNet.configureIntent(this))
|
||||
.setSession(profile.displayName())
|
||||
.setMtu(DataStore.mtu)
|
||||
val ipv6Mode = DataStore.ipv6Mode
|
||||
|
||||
// address
|
||||
builder.addAddress(PRIVATE_VLAN4_CLIENT, 30)
|
||||
if (ipv6Mode != IPv6Mode.DISABLE) {
|
||||
builder.addAddress(PRIVATE_VLAN6_CLIENT, 126)
|
||||
}
|
||||
builder.addDnsServer(PRIVATE_VLAN4_ROUTER)
|
||||
|
||||
// route
|
||||
if (DataStore.bypassLan && !DataStore.bypassLanInCoreOnly) {
|
||||
resources.getStringArray(R.array.bypass_private_route).forEach {
|
||||
val subnet = Subnet.fromString(it)!!
|
||||
builder.addRoute(subnet.address.hostAddress!!, subnet.prefixSize)
|
||||
}
|
||||
builder.addRoute(PRIVATE_VLAN4_ROUTER, 32)
|
||||
builder.addRoute(FAKEDNS_VLAN4_CLIENT, 15)
|
||||
// https://issuetracker.google.com/issues/149636790
|
||||
if (ipv6Mode != IPv6Mode.DISABLE) {
|
||||
builder.addRoute("2000::", 3)
|
||||
}
|
||||
} else {
|
||||
builder.addRoute("0.0.0.0", 0)
|
||||
if (ipv6Mode != IPv6Mode.DISABLE) {
|
||||
builder.addRoute("::", 0)
|
||||
}
|
||||
}
|
||||
|
||||
// ?
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
|
||||
// TODO listen for change?
|
||||
if (SagerNet.underlyingNetwork != null) {
|
||||
builder.setUnderlyingNetworks(arrayOf(SagerNet.underlyingNetwork))
|
||||
}
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) builder.setMetered(metered)
|
||||
|
||||
// app route
|
||||
val packageName = packageName
|
||||
var proxyApps = DataStore.proxyApps
|
||||
var bypass = DataStore.bypass
|
||||
var workaroundSYSTEM = false /* DataStore.tunImplementation == TunImplementation.SYSTEM */
|
||||
var needBypassRootUid = workaroundSYSTEM || data.proxy!!.config.trafficMap.values.any {
|
||||
it.nekoBean?.needBypassRootUid() == true || it.hysteriaBean?.protocol == HysteriaBean.PROTOCOL_FAKETCP
|
||||
}
|
||||
|
||||
if (proxyApps || needBypassRootUid) {
|
||||
val individual = mutableSetOf<String>()
|
||||
val allApps by lazy {
|
||||
packageManager.getInstalledPackages(PackageManager.GET_PERMISSIONS).filter {
|
||||
when (it.packageName) {
|
||||
packageName -> false
|
||||
"android" -> true
|
||||
else -> it.requestedPermissions?.contains(Manifest.permission.INTERNET) == true
|
||||
}
|
||||
}.map {
|
||||
it.packageName
|
||||
}
|
||||
}
|
||||
if (proxyApps) {
|
||||
individual.addAll(DataStore.individual.split('\n').filter { it.isNotBlank() })
|
||||
if (bypass && needBypassRootUid) {
|
||||
val individualNew = allApps.toMutableList()
|
||||
individualNew.removeAll(individual)
|
||||
individual.clear()
|
||||
individual.addAll(individualNew)
|
||||
bypass = false
|
||||
}
|
||||
} else {
|
||||
individual.addAll(allApps)
|
||||
bypass = false
|
||||
}
|
||||
|
||||
val added = mutableListOf<String>()
|
||||
|
||||
individual.apply {
|
||||
// Allow Matsuri itself using VPN.
|
||||
remove(packageName)
|
||||
if (!bypass) add(packageName)
|
||||
}.forEach {
|
||||
try {
|
||||
if (bypass) {
|
||||
builder.addDisallowedApplication(it)
|
||||
} else {
|
||||
builder.addAllowedApplication(it)
|
||||
}
|
||||
added.add(it)
|
||||
} catch (ex: PackageManager.NameNotFoundException) {
|
||||
Logs.w(ex)
|
||||
}
|
||||
}
|
||||
|
||||
if (bypass) {
|
||||
Logs.d("Add bypass: ${added.joinToString(", ")}")
|
||||
} else {
|
||||
Logs.d("Add allow: ${added.joinToString(", ")}")
|
||||
}
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && DataStore.appendHttpProxy) {
|
||||
builder.setHttpProxy(ProxyInfo.buildDirectProxy(LOCALHOST, DataStore.mixedPort))
|
||||
}
|
||||
|
||||
metered = DataStore.meteredNetwork
|
||||
if (Build.VERSION.SDK_INT >= 29) builder.setMetered(metered)
|
||||
conn = builder.establish() ?: throw NullConnectionException()
|
||||
|
||||
// post setup
|
||||
Libcore.setLocalResolver(LocalResolverImpl)
|
||||
|
||||
return conn!!.fd
|
||||
}
|
||||
|
||||
//
|
||||
// val appStats = mutableListOf<AppStats>()
|
||||
//
|
||||
// override fun updateStats(stats: AppStats) {
|
||||
// appStats.add(stats)
|
||||
// }
|
||||
//
|
||||
// fun persistAppStats() {
|
||||
// if (!DataStore.appTrafficStatistics) return
|
||||
// val tun = getTun() ?: return
|
||||
// appStats.clear()
|
||||
// tun.readAppTraffics(this)
|
||||
// val toUpdate = mutableListOf<StatsEntity>()
|
||||
// val all = SagerDatabase.statsDao.all().associateBy { it.packageName }
|
||||
// for (stats in appStats) {
|
||||
// if (stats.nekoConnectionsJSON.isNotBlank()) continue
|
||||
// val packageName = if (stats.uid >= 10000) {
|
||||
// PackageCache.uidMap[stats.uid]?.iterator()?.next() ?: "android"
|
||||
// } else {
|
||||
// "android"
|
||||
// }
|
||||
// if (!all.containsKey(packageName)) {
|
||||
// SagerDatabase.statsDao.create(
|
||||
// StatsEntity(
|
||||
// packageName = packageName,
|
||||
// tcpConnections = stats.tcpConnTotal,
|
||||
// udpConnections = stats.udpConnTotal,
|
||||
// uplink = stats.uplinkTotal,
|
||||
// downlink = stats.downlinkTotal
|
||||
// )
|
||||
// )
|
||||
// } else {
|
||||
// val entity = all[packageName]!!
|
||||
// entity.tcpConnections += stats.tcpConnTotal
|
||||
// entity.udpConnections += stats.udpConnTotal
|
||||
// entity.uplink += stats.uplinkTotal
|
||||
// entity.downlink += stats.downlinkTotal
|
||||
// toUpdate.add(entity)
|
||||
// }
|
||||
// if (toUpdate.isNotEmpty()) {
|
||||
// SagerDatabase.statsDao.update(toUpdate)
|
||||
// }
|
||||
//
|
||||
// }
|
||||
// }
|
||||
|
||||
override fun onRevoke() = stopRunner()
|
||||
|
||||
override fun onDestroy() {
|
||||
Libcore.setLocalResolver(null)
|
||||
DataStore.vpnService = null
|
||||
super.onDestroy()
|
||||
data.binder.close()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,277 @@
|
||||
package io.nekohasekai.sagernet.bg.proto
|
||||
|
||||
import android.os.Build
|
||||
import android.os.SystemClock
|
||||
import io.nekohasekai.sagernet.SagerNet
|
||||
import io.nekohasekai.sagernet.bg.AbstractInstance
|
||||
import io.nekohasekai.sagernet.bg.GuardedProcessPool
|
||||
import io.nekohasekai.sagernet.database.DataStore
|
||||
import io.nekohasekai.sagernet.database.ProxyEntity
|
||||
import io.nekohasekai.sagernet.fmt.ConfigBuildResult
|
||||
import io.nekohasekai.sagernet.fmt.buildConfig
|
||||
import io.nekohasekai.sagernet.fmt.hysteria.HysteriaBean
|
||||
import io.nekohasekai.sagernet.fmt.hysteria.buildHysteriaConfig
|
||||
import io.nekohasekai.sagernet.fmt.naive.NaiveBean
|
||||
import io.nekohasekai.sagernet.fmt.naive.buildNaiveConfig
|
||||
import io.nekohasekai.sagernet.fmt.trojan_go.TrojanGoBean
|
||||
import io.nekohasekai.sagernet.fmt.trojan_go.buildTrojanGoConfig
|
||||
import io.nekohasekai.sagernet.fmt.tuic.TuicBean
|
||||
import io.nekohasekai.sagernet.fmt.tuic.buildTuicConfig
|
||||
import io.nekohasekai.sagernet.ktx.*
|
||||
import io.nekohasekai.sagernet.plugin.PluginManager
|
||||
import kotlinx.coroutines.*
|
||||
import libcore.BoxInstance
|
||||
import libcore.Libcore
|
||||
import moe.matsuri.nb4a.proxy.neko.NekoBean
|
||||
import moe.matsuri.nb4a.proxy.neko.NekoJSInterface
|
||||
import moe.matsuri.nb4a.plugin.NekoPluginManager
|
||||
import moe.matsuri.nb4a.proxy.neko.updateAllConfig
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
abstract class BoxInstance(
|
||||
val profile: ProxyEntity
|
||||
) : AbstractInstance {
|
||||
|
||||
lateinit var config: ConfigBuildResult
|
||||
lateinit var box: BoxInstance
|
||||
|
||||
val pluginPath = hashMapOf<String, PluginManager.InitResult>()
|
||||
val pluginConfigs = hashMapOf<Int, Pair<Int, String>>()
|
||||
val externalInstances = hashMapOf<Int, AbstractInstance>()
|
||||
open lateinit var processes: GuardedProcessPool
|
||||
private var cacheFiles = ArrayList<File>()
|
||||
fun isInitialized(): Boolean {
|
||||
return ::config.isInitialized && ::box.isInitialized
|
||||
}
|
||||
|
||||
protected fun initPlugin(name: String): PluginManager.InitResult {
|
||||
return pluginPath.getOrPut(name) { PluginManager.init(name)!! }
|
||||
}
|
||||
|
||||
protected open fun buildConfig() {
|
||||
config = buildConfig(profile)
|
||||
}
|
||||
|
||||
protected open suspend fun loadConfig() {
|
||||
NekoJSInterface.Default.destroyAllJsi()
|
||||
box = Libcore.newSingBoxInstance(config.config)
|
||||
}
|
||||
|
||||
open suspend fun init() {
|
||||
buildConfig()
|
||||
for ((chain) in config.externalIndex) {
|
||||
chain.entries.forEachIndexed { index, (port, profile) ->
|
||||
when (val bean = profile.requireBean()) {
|
||||
is TrojanGoBean -> {
|
||||
initPlugin("trojan-go-plugin")
|
||||
pluginConfigs[port] = profile.type to bean.buildTrojanGoConfig(port)
|
||||
}
|
||||
is NaiveBean -> {
|
||||
initPlugin("naive-plugin")
|
||||
pluginConfigs[port] = profile.type to bean.buildNaiveConfig(port)
|
||||
}
|
||||
is HysteriaBean -> {
|
||||
initPlugin("hysteria-plugin")
|
||||
pluginConfigs[port] = profile.type to bean.buildHysteriaConfig(port) {
|
||||
File(
|
||||
app.cacheDir, "hysteria_" + SystemClock.elapsedRealtime() + ".ca"
|
||||
).apply {
|
||||
parentFile?.mkdirs()
|
||||
cacheFiles.add(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
is TuicBean -> {
|
||||
initPlugin("tuic-plugin")
|
||||
pluginConfigs[port] = profile.type to bean.buildTuicConfig(port) {
|
||||
File(
|
||||
app.noBackupFilesDir,
|
||||
"tuic_" + SystemClock.elapsedRealtime() + ".ca"
|
||||
).apply {
|
||||
parentFile?.mkdirs()
|
||||
cacheFiles.add(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
is NekoBean -> {
|
||||
// check if plugin binary can be loaded
|
||||
initPlugin(bean.plgId)
|
||||
|
||||
// build config and check if succeed
|
||||
bean.updateAllConfig(port)
|
||||
if (bean.allConfig == null) {
|
||||
throw NekoPluginManager.PluginInternalException(bean.protocolId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
loadConfig()
|
||||
}
|
||||
|
||||
override fun launch() {
|
||||
// TODO move, this is not box
|
||||
val context =
|
||||
if (Build.VERSION.SDK_INT < 24 || SagerNet.user.isUserUnlocked) SagerNet.application else SagerNet.deviceStorage
|
||||
val cache = File(context.cacheDir, "tmpcfg")
|
||||
cache.mkdirs()
|
||||
|
||||
for ((chain) in config.externalIndex) {
|
||||
chain.entries.forEachIndexed { index, (port, profile) ->
|
||||
val bean = profile.requireBean()
|
||||
val needChain = index != chain.size - 1
|
||||
val (profileType, config) = pluginConfigs[port] ?: (0 to "")
|
||||
|
||||
when {
|
||||
externalInstances.containsKey(port) -> {
|
||||
externalInstances[port]!!.launch()
|
||||
}
|
||||
bean is TrojanGoBean -> {
|
||||
val configFile = File(
|
||||
cache, "trojan_go_" + SystemClock.elapsedRealtime() + ".json"
|
||||
)
|
||||
configFile.parentFile?.mkdirs()
|
||||
configFile.writeText(config)
|
||||
cacheFiles.add(configFile)
|
||||
|
||||
val commands = mutableListOf(
|
||||
initPlugin("trojan-go-plugin").path, "-config", configFile.absolutePath
|
||||
)
|
||||
|
||||
processes.start(commands)
|
||||
}
|
||||
bean is NaiveBean -> {
|
||||
val configFile = File(
|
||||
cache, "naive_" + SystemClock.elapsedRealtime() + ".json"
|
||||
)
|
||||
|
||||
configFile.parentFile?.mkdirs()
|
||||
configFile.writeText(config)
|
||||
cacheFiles.add(configFile)
|
||||
|
||||
val envMap = mutableMapOf<String, String>()
|
||||
|
||||
if (bean.certificates.isNotBlank()) {
|
||||
val certFile = File(
|
||||
cache, "naive_" + SystemClock.elapsedRealtime() + ".crt"
|
||||
)
|
||||
|
||||
certFile.parentFile?.mkdirs()
|
||||
certFile.writeText(bean.certificates)
|
||||
cacheFiles.add(certFile)
|
||||
|
||||
envMap["SSL_CERT_FILE"] = certFile.absolutePath
|
||||
}
|
||||
|
||||
val commands = mutableListOf(
|
||||
initPlugin("naive-plugin").path, configFile.absolutePath
|
||||
)
|
||||
|
||||
processes.start(commands, envMap)
|
||||
}
|
||||
bean is HysteriaBean -> {
|
||||
val configFile = File(
|
||||
cache, "hysteria_" + SystemClock.elapsedRealtime() + ".json"
|
||||
)
|
||||
|
||||
configFile.parentFile?.mkdirs()
|
||||
configFile.writeText(config)
|
||||
cacheFiles.add(configFile)
|
||||
|
||||
val commands = mutableListOf(
|
||||
initPlugin("hysteria-plugin").path,
|
||||
"--no-check",
|
||||
"--config",
|
||||
configFile.absolutePath,
|
||||
"--log-level",
|
||||
if (DataStore.enableLog) "trace" else "warn",
|
||||
"client"
|
||||
)
|
||||
|
||||
if (bean.protocol == HysteriaBean.PROTOCOL_FAKETCP) {
|
||||
commands.addAll(0, listOf("su", "-c"))
|
||||
}
|
||||
|
||||
processes.start(commands)
|
||||
}
|
||||
bean is NekoBean -> {
|
||||
// config built from JS
|
||||
val nekoRunConfigs = bean.allConfig.optJSONArray("nekoRunConfigs")
|
||||
val configs = mutableMapOf<String, String>()
|
||||
|
||||
nekoRunConfigs?.forEach { _, any ->
|
||||
any as JSONObject
|
||||
|
||||
val name = any.getString("name")
|
||||
val configFile = File(cache, name)
|
||||
configFile.parentFile?.mkdirs()
|
||||
val content = any.getString("content")
|
||||
configFile.writeText(content)
|
||||
|
||||
cacheFiles.add(configFile)
|
||||
configs[name] = configFile.absolutePath
|
||||
|
||||
Logs.d(name + "\n\n" + content)
|
||||
}
|
||||
|
||||
val nekoCommands = bean.allConfig.getJSONArray("nekoCommands")
|
||||
val commands = mutableListOf<String>()
|
||||
|
||||
nekoCommands.forEach { _, any ->
|
||||
if (any is String) {
|
||||
if (configs.containsKey(any)) {
|
||||
commands.add(configs[any]!!)
|
||||
} else if (any == "%exe%") {
|
||||
commands.add(initPlugin(bean.plgId).path)
|
||||
} else {
|
||||
commands.add(any)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
processes.start(commands)
|
||||
}
|
||||
bean is TuicBean -> {
|
||||
val configFile = File(
|
||||
context.noBackupFilesDir,
|
||||
"tuic_" + SystemClock.elapsedRealtime() + ".json"
|
||||
)
|
||||
|
||||
configFile.parentFile?.mkdirs()
|
||||
configFile.writeText(config)
|
||||
cacheFiles.add(configFile)
|
||||
|
||||
val commands = mutableListOf(
|
||||
initPlugin("tuic-plugin").path,
|
||||
"-c",
|
||||
configFile.absolutePath,
|
||||
)
|
||||
|
||||
processes.start(commands)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
box.start()
|
||||
}
|
||||
|
||||
@Suppress("EXPERIMENTAL_API_USAGE")
|
||||
override fun close() {
|
||||
for (instance in externalInstances.values) {
|
||||
runCatching {
|
||||
instance.close()
|
||||
}
|
||||
}
|
||||
|
||||
cacheFiles.removeAll { it.delete(); true }
|
||||
|
||||
if (::processes.isInitialized) processes.close(GlobalScope + Dispatchers.IO)
|
||||
|
||||
if (::box.isInitialized) {
|
||||
box.close()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
package io.nekohasekai.sagernet.bg.proto
|
||||
|
||||
import io.nekohasekai.sagernet.bg.BaseService
|
||||
import io.nekohasekai.sagernet.database.ProxyEntity
|
||||
import io.nekohasekai.sagernet.ktx.Logs
|
||||
import io.nekohasekai.sagernet.ktx.runOnIoDispatcher
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
class ProxyInstance(profile: ProxyEntity, val service: BaseService.Interface) :
|
||||
BoxInstance(profile) {
|
||||
|
||||
// for TrafficLooper
|
||||
private var looper: TrafficLooper? = null
|
||||
|
||||
override fun buildConfig() {
|
||||
super.buildConfig()
|
||||
Logs.d(config.config)
|
||||
}
|
||||
|
||||
override suspend fun init() {
|
||||
super.init()
|
||||
pluginConfigs.forEach { (_, plugin) ->
|
||||
val (_, content) = plugin
|
||||
Logs.d(content)
|
||||
}
|
||||
}
|
||||
|
||||
override fun launch() {
|
||||
box.setAsMain()
|
||||
super.launch()
|
||||
runOnIoDispatcher {
|
||||
looper = TrafficLooper(service.data, this)
|
||||
looper?.start()
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
super.close()
|
||||
runBlocking {
|
||||
looper?.stop()
|
||||
looper = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,48 @@
|
||||
package io.nekohasekai.sagernet.bg.proto
|
||||
|
||||
import io.nekohasekai.sagernet.BuildConfig
|
||||
import io.nekohasekai.sagernet.bg.GuardedProcessPool
|
||||
import io.nekohasekai.sagernet.database.ProxyEntity
|
||||
import io.nekohasekai.sagernet.fmt.buildConfig
|
||||
import io.nekohasekai.sagernet.ktx.Logs
|
||||
import io.nekohasekai.sagernet.ktx.runOnDefaultDispatcher
|
||||
import io.nekohasekai.sagernet.ktx.tryResume
|
||||
import io.nekohasekai.sagernet.ktx.tryResumeWithException
|
||||
import libcore.Libcore
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
class TestInstance(profile: ProxyEntity, val link: String, val timeout: Int) :
|
||||
BoxInstance(profile) {
|
||||
|
||||
suspend fun doTest(): Int {
|
||||
return suspendCoroutine { c ->
|
||||
processes = GuardedProcessPool {
|
||||
Logs.w(it)
|
||||
c.tryResumeWithException(it)
|
||||
}
|
||||
runOnDefaultDispatcher {
|
||||
use {
|
||||
try {
|
||||
init()
|
||||
launch()
|
||||
c.tryResume(Libcore.urlTest(box, link, timeout))
|
||||
} catch (e: Exception) {
|
||||
c.tryResumeWithException(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun buildConfig() {
|
||||
config = buildConfig(profile, true)
|
||||
}
|
||||
|
||||
override suspend fun loadConfig() {
|
||||
// don't call destroyAllJsi here
|
||||
if (BuildConfig.DEBUG) Logs.d(config.config)
|
||||
box = Libcore.newSingBoxInstance(config.config)
|
||||
box.forTest = true
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,130 @@
|
||||
package io.nekohasekai.sagernet.bg.proto
|
||||
|
||||
import io.nekohasekai.sagernet.aidl.SpeedDisplayData
|
||||
import io.nekohasekai.sagernet.aidl.TrafficData
|
||||
import io.nekohasekai.sagernet.bg.BaseService
|
||||
import io.nekohasekai.sagernet.database.DataStore
|
||||
import io.nekohasekai.sagernet.database.ProfileManager
|
||||
import io.nekohasekai.sagernet.ktx.Logs
|
||||
import kotlinx.coroutines.*
|
||||
import kotlin.time.DurationUnit
|
||||
import kotlin.time.toDuration
|
||||
|
||||
class TrafficLooper
|
||||
(
|
||||
val data: BaseService.Data, private val sc: CoroutineScope
|
||||
) {
|
||||
|
||||
private var job: Job? = null
|
||||
private val items = mutableMapOf<String, TrafficUpdater.TrafficLooperData>()
|
||||
|
||||
suspend fun stop() {
|
||||
job?.cancel()
|
||||
// finally
|
||||
val traffic = mutableMapOf<Long, TrafficData>()
|
||||
data.proxy?.config?.trafficMap?.forEach { (tag, ent) ->
|
||||
val item = items[tag] ?: return@forEach
|
||||
ent.rx = item.rx
|
||||
ent.tx = item.tx
|
||||
ProfileManager.updateProfile(ent) // update DB
|
||||
traffic[ent.id] = TrafficData(
|
||||
id = ent.id,
|
||||
rx = ent.rx,
|
||||
tx = ent.tx,
|
||||
)
|
||||
}
|
||||
data.binder.broadcast { b ->
|
||||
for (t in traffic) {
|
||||
b.cbTrafficUpdate(t.value)
|
||||
}
|
||||
}
|
||||
Logs.d("finally traffic post done")
|
||||
}
|
||||
|
||||
fun start() {
|
||||
job = sc.launch { loop() }
|
||||
}
|
||||
|
||||
private suspend fun loop() {
|
||||
val delayMs = DataStore.speedInterval
|
||||
val showDirectSpeed = DataStore.showDirectSpeed
|
||||
if (delayMs == 0) return
|
||||
|
||||
var trafficUpdater: TrafficUpdater? = null
|
||||
var proxy: ProxyInstance?
|
||||
var itemMain: TrafficUpdater.TrafficLooperData? = null
|
||||
var itemMainBase: TrafficUpdater.TrafficLooperData? = null
|
||||
var itemBypass: TrafficUpdater.TrafficLooperData? = null
|
||||
|
||||
while (sc.isActive) {
|
||||
delay(delayMs.toDuration(DurationUnit.MILLISECONDS))
|
||||
proxy = data.proxy ?: continue
|
||||
|
||||
if (trafficUpdater == null) {
|
||||
if (!proxy.isInitialized()) continue
|
||||
items.clear()
|
||||
itemBypass = TrafficUpdater.TrafficLooperData(tag = "bypass")
|
||||
items["bypass"] = itemBypass
|
||||
// proxy.config.trafficMap.forEach { (tag, ent) ->
|
||||
proxy.config.outboundTags.forEach { tag ->
|
||||
// TODO g-xx query traffic return 0?
|
||||
val ent = proxy.config.trafficMap[tag] ?: return@forEach
|
||||
val item = TrafficUpdater.TrafficLooperData(
|
||||
tag = tag,
|
||||
rx = ent.rx,
|
||||
tx = ent.tx,
|
||||
)
|
||||
if (tag == proxy.config.outboundTagMain) {
|
||||
itemMain = item
|
||||
itemMainBase = TrafficUpdater.TrafficLooperData(
|
||||
tag = tag,
|
||||
rx = ent.rx,
|
||||
tx = ent.tx,
|
||||
)
|
||||
}
|
||||
items[tag] = item
|
||||
Logs.d("traffic count $tag to ${ent.id}")
|
||||
}
|
||||
trafficUpdater = TrafficUpdater(
|
||||
box = proxy.box, items = items
|
||||
)
|
||||
proxy.box.setV2rayStats(items.keys.joinToString("\n"))
|
||||
}
|
||||
|
||||
trafficUpdater.updateAll()
|
||||
if (!sc.isActive) return
|
||||
|
||||
// speed
|
||||
val speed = SpeedDisplayData(
|
||||
itemMain!!.txRate,
|
||||
itemMain!!.rxRate,
|
||||
if (showDirectSpeed) itemBypass!!.txRate else 0L,
|
||||
if (showDirectSpeed) itemBypass!!.rxRate else 0L,
|
||||
itemMain!!.tx - itemMainBase!!.tx,
|
||||
itemMain!!.rx - itemMainBase!!.rx
|
||||
)
|
||||
|
||||
// traffic
|
||||
val traffic = mutableMapOf<Long, TrafficData>()
|
||||
proxy.config.trafficMap.forEach { (tag, ent) ->
|
||||
val item = items[tag] ?: return@forEach
|
||||
ent.rx = item.rx
|
||||
ent.tx = item.tx
|
||||
// ProfileManager.updateProfile(ent) // update DB
|
||||
traffic[ent.id] = TrafficData(
|
||||
id = ent.id,
|
||||
rx = ent.rx,
|
||||
tx = ent.tx,
|
||||
) // display
|
||||
}
|
||||
|
||||
// broadcast
|
||||
data.binder.broadcast { b ->
|
||||
b.cbSpeedUpdate(speed)
|
||||
for (t in traffic) {
|
||||
b.cbTrafficUpdate(t.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,69 @@
|
||||
package io.nekohasekai.sagernet.bg.proto
|
||||
|
||||
import io.nekohasekai.sagernet.ktx.onIoDispatcher
|
||||
|
||||
class TrafficUpdater(
|
||||
private val box: libcore.BoxInstance,
|
||||
val items: Map<String, TrafficLooperData>, // contain "bypass"
|
||||
) {
|
||||
|
||||
class TrafficLooperData(
|
||||
var tag: String,
|
||||
var tx: Long = 0,
|
||||
var rx: Long = 0,
|
||||
var txRate: Long = 0,
|
||||
var rxRate: Long = 0,
|
||||
var lastUpdate: Long = 0,
|
||||
)
|
||||
|
||||
private suspend fun updateOne(item: TrafficLooperData): TrafficLooperData {
|
||||
// last update
|
||||
val now = System.currentTimeMillis()
|
||||
val interval = now - item.lastUpdate
|
||||
item.lastUpdate = now
|
||||
if (interval <= 0) return item.apply {
|
||||
rxRate = 0
|
||||
txRate = 0
|
||||
}
|
||||
|
||||
// query
|
||||
var tx = 0L
|
||||
var rx = 0L
|
||||
onIoDispatcher {
|
||||
tx = box.queryStats(item.tag, "uplink")
|
||||
rx = box.queryStats(item.tag, "downlink")
|
||||
}
|
||||
|
||||
// add diff
|
||||
item.rx += rx
|
||||
item.tx += tx
|
||||
item.rxRate = rx * 1000 / interval
|
||||
item.txRate = tx * 1000 / interval
|
||||
|
||||
// return diff
|
||||
return TrafficLooperData(
|
||||
tag = item.tag,
|
||||
rx = rx,
|
||||
tx = tx,
|
||||
rxRate = item.rxRate,
|
||||
txRate = item.txRate,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun updateAll() {
|
||||
val updated = mutableMapOf<String, TrafficLooperData>() // diffs
|
||||
items.forEach { (tag, item) ->
|
||||
var diff = updated[tag]
|
||||
// query a tag only once
|
||||
if (diff == null) {
|
||||
diff = updateOne(item)
|
||||
updated[tag] = diff
|
||||
} else {
|
||||
item.rx += diff.rx
|
||||
item.tx += diff.tx
|
||||
item.rxRate += diff.rxRate
|
||||
item.txRate += diff.txRate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
package io.nekohasekai.sagernet.bg.proto
|
||||
|
||||
import io.nekohasekai.sagernet.database.DataStore
|
||||
import io.nekohasekai.sagernet.database.ProxyEntity
|
||||
|
||||
class UrlTest {
|
||||
|
||||
val link = DataStore.connectionTestURL
|
||||
val timeout = 3000
|
||||
|
||||
suspend fun doTest(profile: ProxyEntity): Int {
|
||||
return TestInstance(profile, link, timeout).doTest()
|
||||
}
|
||||
|
||||
}
|
||||
250
app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt
Normal file
250
app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt
Normal file
@ -0,0 +1,250 @@
|
||||
package io.nekohasekai.sagernet.database
|
||||
|
||||
import android.os.Binder
|
||||
import androidx.preference.PreferenceDataStore
|
||||
import io.nekohasekai.sagernet.*
|
||||
import io.nekohasekai.sagernet.bg.BaseService
|
||||
import io.nekohasekai.sagernet.bg.VpnService
|
||||
import io.nekohasekai.sagernet.database.preference.OnPreferenceDataStoreChangeListener
|
||||
import io.nekohasekai.sagernet.database.preference.PublicDatabase
|
||||
import io.nekohasekai.sagernet.database.preference.RoomPreferenceDataStore
|
||||
import io.nekohasekai.sagernet.ktx.*
|
||||
import moe.matsuri.nb4a.TempDatabase
|
||||
|
||||
object DataStore : OnPreferenceDataStoreChangeListener {
|
||||
|
||||
// share service state in main process
|
||||
@Volatile
|
||||
var serviceState = BaseService.State.Idle
|
||||
|
||||
val configurationStore = RoomPreferenceDataStore(PublicDatabase.kvPairDao)
|
||||
val profileCacheStore = RoomPreferenceDataStore(TempDatabase.profileCacheDao)
|
||||
|
||||
// last used, but may not be running
|
||||
var currentProfile by configurationStore.long(Key.PROFILE_CURRENT)
|
||||
|
||||
var selectedProxy by configurationStore.long(Key.PROFILE_ID)
|
||||
var selectedGroup by configurationStore.long(Key.PROFILE_GROUP) { currentGroupId() } // "ungrouped" group id = 1
|
||||
|
||||
// only in bg process
|
||||
var vpnService: VpnService? = null
|
||||
var baseService: BaseService.Interface? = null
|
||||
|
||||
// only in GUI process
|
||||
var postLogListener: ((String) -> Unit)? = null
|
||||
|
||||
fun currentGroupId(): Long {
|
||||
val currentSelected = configurationStore.getLong(Key.PROFILE_GROUP, -1)
|
||||
if (currentSelected > 0L) return currentSelected
|
||||
val groups = SagerDatabase.groupDao.allGroups()
|
||||
if (groups.isNotEmpty()) {
|
||||
val groupId = groups[0].id
|
||||
selectedGroup = groupId
|
||||
return groupId
|
||||
}
|
||||
val groupId = SagerDatabase.groupDao.createGroup(ProxyGroup(ungrouped = true))
|
||||
selectedGroup = groupId
|
||||
return groupId
|
||||
}
|
||||
|
||||
fun currentGroup(): ProxyGroup {
|
||||
var group: ProxyGroup? = null
|
||||
val currentSelected = configurationStore.getLong(Key.PROFILE_GROUP, -1)
|
||||
if (currentSelected > 0L) {
|
||||
group = SagerDatabase.groupDao.getById(currentSelected)
|
||||
}
|
||||
if (group != null) return group
|
||||
val groups = SagerDatabase.groupDao.allGroups()
|
||||
if (groups.isEmpty()) {
|
||||
group = ProxyGroup(ungrouped = true).apply {
|
||||
id = SagerDatabase.groupDao.createGroup(this)
|
||||
}
|
||||
} else {
|
||||
group = groups[0]
|
||||
}
|
||||
selectedGroup = group.id
|
||||
return group
|
||||
}
|
||||
|
||||
fun selectedGroupForImport(): Long {
|
||||
val current = currentGroup()
|
||||
if (current.type == GroupType.BASIC) return current.id
|
||||
val groups = SagerDatabase.groupDao.allGroups()
|
||||
return groups.find { it.type == GroupType.BASIC }!!.id
|
||||
}
|
||||
|
||||
var nekoPlugins by configurationStore.string(Key.NEKO_PLUGIN_MANAGED)
|
||||
var appTLSVersion by configurationStore.string(Key.APP_TLS_VERSION)
|
||||
var enableClashAPI by configurationStore.boolean(Key.ENABLE_CLASH_API)
|
||||
var showBottomBar by configurationStore.boolean(Key.SHOW_BOTTOM_BAR)
|
||||
|
||||
//
|
||||
|
||||
var isExpert by configurationStore.boolean(Key.APP_EXPERT)
|
||||
var appTheme by configurationStore.int(Key.APP_THEME)
|
||||
var nightTheme by configurationStore.stringToInt(Key.NIGHT_THEME)
|
||||
var serviceMode by configurationStore.string(Key.SERVICE_MODE) { Key.MODE_VPN }
|
||||
|
||||
// var domainStrategy by configurationStore.string(Key.DOMAIN_STRATEGY) { "AsIs" }
|
||||
var trafficSniffing by configurationStore.boolean(Key.TRAFFIC_SNIFFING) { true }
|
||||
var resolveDestination by configurationStore.boolean(Key.RESOLVE_DESTINATION)
|
||||
|
||||
// var tcpKeepAliveInterval by configurationStore.stringToInt(Key.TCP_KEEP_ALIVE_INTERVAL) { 15 }
|
||||
var mtu by configurationStore.stringToInt(Key.MTU) { 9000 }
|
||||
|
||||
var bypassLan by configurationStore.boolean(Key.BYPASS_LAN)
|
||||
var bypassLanInCoreOnly by configurationStore.boolean(Key.BYPASS_LAN_IN_CORE_ONLY)
|
||||
|
||||
var allowAccess by configurationStore.boolean(Key.ALLOW_ACCESS)
|
||||
var speedInterval by configurationStore.stringToInt(Key.SPEED_INTERVAL)
|
||||
|
||||
var remoteDns by configurationStore.string(Key.REMOTE_DNS) { "https://8.8.8.8/dns-query" }
|
||||
var directDns by configurationStore.string(Key.DIRECT_DNS) { "https://223.5.5.5/dns-query" }
|
||||
var directDnsUseSystem by configurationStore.boolean(Key.DIRECT_DNS_USE_SYSTEM)
|
||||
var enableDnsRouting by configurationStore.boolean(Key.ENABLE_DNS_ROUTING) { true }
|
||||
var enableFakeDns by configurationStore.boolean(Key.ENABLE_FAKEDNS)
|
||||
var dnsNetwork by configurationStore.stringSet(Key.DNS_NETWORK)
|
||||
|
||||
var rulesProvider by configurationStore.stringToInt(Key.RULES_PROVIDER)
|
||||
var enableLog by configurationStore.boolean(Key.ENABLE_LOG)
|
||||
var logBufSize by configurationStore.int(Key.LOG_BUF_SIZE) { 0 }
|
||||
var acquireWakeLock by configurationStore.boolean(Key.ACQUIRE_WAKE_LOCK)
|
||||
|
||||
// hopefully hashCode = mHandle doesn't change, currently this is true from KitKat to Nougat
|
||||
private val userIndex by lazy { Binder.getCallingUserHandle().hashCode() }
|
||||
var mixedPort: Int
|
||||
get() = getLocalPort(Key.MIXED_PORT, 2080)
|
||||
set(value) = saveLocalPort(Key.MIXED_PORT, value)
|
||||
var localDNSPort: Int
|
||||
get() = getLocalPort(Key.LOCAL_DNS_PORT, 6450)
|
||||
set(value) {
|
||||
saveLocalPort(Key.LOCAL_DNS_PORT, value)
|
||||
}
|
||||
var transproxyPort: Int
|
||||
get() = getLocalPort(Key.TRANSPROXY_PORT, 9200)
|
||||
set(value) = saveLocalPort(Key.TRANSPROXY_PORT, value)
|
||||
|
||||
fun initGlobal() {
|
||||
if (configurationStore.getString(Key.MIXED_PORT) == null) {
|
||||
mixedPort = mixedPort
|
||||
}
|
||||
if (configurationStore.getString(Key.LOCAL_DNS_PORT) == null) {
|
||||
localDNSPort = localDNSPort
|
||||
}
|
||||
if (configurationStore.getString(Key.TRANSPROXY_PORT) == null) {
|
||||
transproxyPort = transproxyPort
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun getLocalPort(key: String, default: Int): Int {
|
||||
return parsePort(configurationStore.getString(key), default + userIndex)
|
||||
}
|
||||
|
||||
private fun saveLocalPort(key: String, value: Int) {
|
||||
configurationStore.putString(key, "$value")
|
||||
}
|
||||
|
||||
var ipv6Mode by configurationStore.stringToInt(Key.IPV6_MODE) { IPv6Mode.DISABLE }
|
||||
|
||||
var meteredNetwork by configurationStore.boolean(Key.METERED_NETWORK)
|
||||
var proxyApps by configurationStore.boolean(Key.PROXY_APPS)
|
||||
var bypass by configurationStore.boolean(Key.BYPASS_MODE) { true }
|
||||
var individual by configurationStore.string(Key.INDIVIDUAL)
|
||||
var showDirectSpeed by configurationStore.boolean(Key.SHOW_DIRECT_SPEED) { true }
|
||||
|
||||
var appendHttpProxy by configurationStore.boolean(Key.APPEND_HTTP_PROXY)
|
||||
var requireTransproxy by configurationStore.boolean(Key.REQUIRE_TRANSPROXY)
|
||||
var transproxyMode by configurationStore.stringToInt(Key.TRANSPROXY_MODE)
|
||||
var connectionTestURL by configurationStore.string(Key.CONNECTION_TEST_URL) { CONNECTION_TEST_URL }
|
||||
var alwaysShowAddress by configurationStore.boolean(Key.ALWAYS_SHOW_ADDRESS)
|
||||
|
||||
var tunImplementation by configurationStore.stringToInt(Key.TUN_IMPLEMENTATION) { TunImplementation.SYSTEM }
|
||||
var profileTrafficStatistics by configurationStore.boolean(Key.PROFILE_TRAFFIC_STATISTICS) { true }
|
||||
|
||||
var yacdURL by configurationStore.string("yacdURL") { "http://127.0.0.1:9090/ui" }
|
||||
|
||||
// protocol
|
||||
|
||||
var muxProtocols by configurationStore.stringSet(Key.MUX_PROTOCOLS)
|
||||
var muxConcurrency by configurationStore.stringToInt(Key.MUX_CONCURRENCY) { 8 }
|
||||
|
||||
// old cache, DO NOT ADD
|
||||
|
||||
var dirty by profileCacheStore.boolean(Key.PROFILE_DIRTY)
|
||||
var editingId by profileCacheStore.long(Key.PROFILE_ID)
|
||||
var editingGroup by profileCacheStore.long(Key.PROFILE_GROUP)
|
||||
var profileName by profileCacheStore.string(Key.PROFILE_NAME)
|
||||
var serverAddress by profileCacheStore.string(Key.SERVER_ADDRESS)
|
||||
var serverPort by profileCacheStore.stringToInt(Key.SERVER_PORT)
|
||||
var serverUsername by profileCacheStore.string(Key.SERVER_USERNAME)
|
||||
var serverPassword by profileCacheStore.string(Key.SERVER_PASSWORD)
|
||||
var serverPassword1 by profileCacheStore.string(Key.SERVER_PASSWORD1)
|
||||
var serverMethod by profileCacheStore.string(Key.SERVER_METHOD)
|
||||
|
||||
var sharedStorage by profileCacheStore.string("sharedStorage")
|
||||
|
||||
var serverProtocol by profileCacheStore.string(Key.SERVER_PROTOCOL)
|
||||
var serverObfs by profileCacheStore.string(Key.SERVER_OBFS)
|
||||
|
||||
var serverNetwork by profileCacheStore.string(Key.SERVER_NETWORK)
|
||||
var serverHost by profileCacheStore.string(Key.SERVER_HOST)
|
||||
var serverPath by profileCacheStore.string(Key.SERVER_PATH)
|
||||
var serverSNI by profileCacheStore.string(Key.SERVER_SNI)
|
||||
var serverEncryption by profileCacheStore.string(Key.SERVER_ENCRYPTION)
|
||||
var serverALPN by profileCacheStore.string(Key.SERVER_ALPN)
|
||||
var serverCertificates by profileCacheStore.string(Key.SERVER_CERTIFICATES)
|
||||
var serverHeaders by profileCacheStore.string(Key.SERVER_HEADERS)
|
||||
var serverAllowInsecure by profileCacheStore.boolean(Key.SERVER_ALLOW_INSECURE)
|
||||
|
||||
var serverAuthType by profileCacheStore.stringToInt(Key.SERVER_AUTH_TYPE)
|
||||
var serverUploadSpeed by profileCacheStore.stringToInt(Key.SERVER_UPLOAD_SPEED)
|
||||
var serverDownloadSpeed by profileCacheStore.stringToInt(Key.SERVER_DOWNLOAD_SPEED)
|
||||
var serverStreamReceiveWindow by profileCacheStore.stringToIntIfExists(Key.SERVER_STREAM_RECEIVE_WINDOW)
|
||||
var serverConnectionReceiveWindow by profileCacheStore.stringToIntIfExists(Key.SERVER_CONNECTION_RECEIVE_WINDOW)
|
||||
var serverMTU by profileCacheStore.stringToInt(Key.SERVER_MTU) { 1420 }
|
||||
var serverDisableMtuDiscovery by profileCacheStore.boolean(Key.SERVER_DISABLE_MTU_DISCOVERY)
|
||||
var serverHopInterval by profileCacheStore.stringToInt(Key.SERVER_HOP_INTERVAL) { 10 }
|
||||
|
||||
var serverProtocolVersion by profileCacheStore.stringToInt(Key.SERVER_PROTOCOL)
|
||||
var serverPrivateKey by profileCacheStore.string(Key.SERVER_PRIVATE_KEY)
|
||||
var serverInsecureConcurrency by profileCacheStore.stringToInt(Key.SERVER_INSECURE_CONCURRENCY)
|
||||
|
||||
var serverUDPRelayMode by profileCacheStore.string(Key.SERVER_UDP_RELAY_MODE)
|
||||
var serverCongestionController by profileCacheStore.string(Key.SERVER_CONGESTION_CONTROLLER)
|
||||
var serverDisableSNI by profileCacheStore.boolean(Key.SERVER_DISABLE_SNI)
|
||||
var serverReduceRTT by profileCacheStore.boolean(Key.SERVER_REDUCE_RTT)
|
||||
var serverFastConnect by profileCacheStore.boolean(Key.SERVER_FAST_CONNECT)
|
||||
|
||||
var routeName by profileCacheStore.string(Key.ROUTE_NAME)
|
||||
var routeDomain by profileCacheStore.string(Key.ROUTE_DOMAIN)
|
||||
var routeIP by profileCacheStore.string(Key.ROUTE_IP)
|
||||
var routePort by profileCacheStore.string(Key.ROUTE_PORT)
|
||||
var routeSourcePort by profileCacheStore.string(Key.ROUTE_SOURCE_PORT)
|
||||
var routeNetwork by profileCacheStore.string(Key.ROUTE_NETWORK)
|
||||
var routeSource by profileCacheStore.string(Key.ROUTE_SOURCE)
|
||||
var routeProtocol by profileCacheStore.string(Key.ROUTE_PROTOCOL)
|
||||
var routeOutbound by profileCacheStore.stringToInt(Key.ROUTE_OUTBOUND)
|
||||
var routeOutboundRule by profileCacheStore.long(Key.ROUTE_OUTBOUND_RULE)
|
||||
var routePackages by profileCacheStore.string(Key.ROUTE_PACKAGES)
|
||||
|
||||
|
||||
var serverConfig by profileCacheStore.string(Key.SERVER_CONFIG)
|
||||
|
||||
var groupName by profileCacheStore.string(Key.GROUP_NAME)
|
||||
var groupType by profileCacheStore.stringToInt(Key.GROUP_TYPE)
|
||||
var groupOrder by profileCacheStore.stringToInt(Key.GROUP_ORDER)
|
||||
|
||||
var subscriptionLink by profileCacheStore.string(Key.SUBSCRIPTION_LINK)
|
||||
var subscriptionForceResolve by profileCacheStore.boolean(Key.SUBSCRIPTION_FORCE_RESOLVE)
|
||||
var subscriptionDeduplication by profileCacheStore.boolean(Key.SUBSCRIPTION_DEDUPLICATION)
|
||||
var subscriptionUpdateWhenConnectedOnly by profileCacheStore.boolean(Key.SUBSCRIPTION_UPDATE_WHEN_CONNECTED_ONLY)
|
||||
var subscriptionUserAgent by profileCacheStore.string(Key.SUBSCRIPTION_USER_AGENT)
|
||||
var subscriptionAutoUpdate by profileCacheStore.boolean(Key.SUBSCRIPTION_AUTO_UPDATE)
|
||||
var subscriptionAutoUpdateDelay by profileCacheStore.stringToInt(Key.SUBSCRIPTION_AUTO_UPDATE_DELAY) { 360 }
|
||||
|
||||
var rulesFirstCreate by profileCacheStore.boolean("rulesFirstCreate")
|
||||
|
||||
override fun onPreferenceDataStoreChanged(store: PreferenceDataStore, key: String) {
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,114 @@
|
||||
package io.nekohasekai.sagernet.database
|
||||
|
||||
import io.nekohasekai.sagernet.GroupType
|
||||
import io.nekohasekai.sagernet.bg.SubscriptionUpdater
|
||||
import io.nekohasekai.sagernet.ktx.applyDefaultValues
|
||||
|
||||
object GroupManager {
|
||||
|
||||
interface Listener {
|
||||
suspend fun groupAdd(group: ProxyGroup)
|
||||
suspend fun groupUpdated(group: ProxyGroup)
|
||||
|
||||
suspend fun groupRemoved(groupId: Long)
|
||||
suspend fun groupUpdated(groupId: Long)
|
||||
}
|
||||
|
||||
interface Interface {
|
||||
suspend fun confirm(message: String): Boolean
|
||||
suspend fun alert(message: String)
|
||||
suspend fun onUpdateSuccess(
|
||||
group: ProxyGroup,
|
||||
changed: Int,
|
||||
added: List<String>,
|
||||
updated: Map<String, String>,
|
||||
deleted: List<String>,
|
||||
duplicate: List<String>,
|
||||
byUser: Boolean
|
||||
)
|
||||
|
||||
suspend fun onUpdateFailure(group: ProxyGroup, message: String)
|
||||
}
|
||||
|
||||
private val listeners = ArrayList<Listener>()
|
||||
var userInterface: Interface? = null
|
||||
|
||||
suspend fun iterator(what: suspend Listener.() -> Unit) {
|
||||
synchronized(listeners) {
|
||||
listeners.toList()
|
||||
}.forEach { listener ->
|
||||
what(listener)
|
||||
}
|
||||
}
|
||||
|
||||
fun addListener(listener: Listener) {
|
||||
synchronized(listeners) {
|
||||
listeners.add(listener)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeListener(listener: Listener) {
|
||||
synchronized(listeners) {
|
||||
listeners.remove(listener)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearGroup(groupId: Long) {
|
||||
DataStore.selectedProxy = 0L
|
||||
SagerDatabase.proxyDao.deleteAll(groupId)
|
||||
iterator { groupUpdated(groupId) }
|
||||
}
|
||||
|
||||
fun rearrange(groupId: Long) {
|
||||
val entities = SagerDatabase.proxyDao.getByGroup(groupId)
|
||||
for (index in entities.indices) {
|
||||
entities[index].userOrder = (index + 1).toLong()
|
||||
}
|
||||
SagerDatabase.proxyDao.updateProxy(entities)
|
||||
}
|
||||
|
||||
suspend fun postUpdate(group: ProxyGroup) {
|
||||
iterator { groupUpdated(group) }
|
||||
}
|
||||
|
||||
suspend fun postUpdate(groupId: Long) {
|
||||
postUpdate(SagerDatabase.groupDao.getById(groupId) ?: return)
|
||||
}
|
||||
|
||||
suspend fun postReload(groupId: Long) {
|
||||
iterator { groupUpdated(groupId) }
|
||||
}
|
||||
|
||||
suspend fun createGroup(group: ProxyGroup): ProxyGroup {
|
||||
group.userOrder = SagerDatabase.groupDao.nextOrder() ?: 1
|
||||
group.id = SagerDatabase.groupDao.createGroup(group.applyDefaultValues())
|
||||
iterator { groupAdd(group) }
|
||||
if (group.type == GroupType.SUBSCRIPTION) {
|
||||
SubscriptionUpdater.reconfigureUpdater()
|
||||
}
|
||||
return group
|
||||
}
|
||||
|
||||
suspend fun updateGroup(group: ProxyGroup) {
|
||||
SagerDatabase.groupDao.updateGroup(group)
|
||||
iterator { groupUpdated(group) }
|
||||
if (group.type == GroupType.SUBSCRIPTION) {
|
||||
SubscriptionUpdater.reconfigureUpdater()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deleteGroup(groupId: Long) {
|
||||
SagerDatabase.groupDao.deleteById(groupId)
|
||||
SagerDatabase.proxyDao.deleteByGroup(groupId)
|
||||
iterator { groupRemoved(groupId) }
|
||||
SubscriptionUpdater.reconfigureUpdater()
|
||||
}
|
||||
|
||||
suspend fun deleteGroup(group: List<ProxyGroup>) {
|
||||
SagerDatabase.groupDao.deleteGroup(group)
|
||||
SagerDatabase.proxyDao.deleteByGroup(group.map { it.id }.toLongArray())
|
||||
for (proxyGroup in group) iterator { groupRemoved(proxyGroup.id) }
|
||||
SubscriptionUpdater.reconfigureUpdater()
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
package io.nekohasekai.sagernet.database;
|
||||
|
||||
import android.os.Parcel;
|
||||
|
||||
/**
|
||||
* see: https://youtrack.jetbrains.com/issue/KT-19853
|
||||
*/
|
||||
public class ParcelizeBridge {
|
||||
|
||||
public static RuleEntity createRule(Parcel parcel) {
|
||||
return (RuleEntity) RuleEntity.CREATOR.createFromParcel(parcel);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,258 @@
|
||||
package io.nekohasekai.sagernet.database
|
||||
|
||||
import android.database.sqlite.SQLiteCantOpenDatabaseException
|
||||
import io.nekohasekai.sagernet.R
|
||||
import io.nekohasekai.sagernet.aidl.SpeedDisplayData
|
||||
import io.nekohasekai.sagernet.aidl.TrafficData
|
||||
import io.nekohasekai.sagernet.fmt.AbstractBean
|
||||
import io.nekohasekai.sagernet.ktx.Logs
|
||||
import io.nekohasekai.sagernet.ktx.app
|
||||
import io.nekohasekai.sagernet.ktx.applyDefaultValues
|
||||
import java.io.IOException
|
||||
import java.sql.SQLException
|
||||
import java.util.*
|
||||
|
||||
|
||||
object ProfileManager {
|
||||
|
||||
interface Listener {
|
||||
suspend fun onAdd(profile: ProxyEntity)
|
||||
suspend fun onUpdated(data: TrafficData)
|
||||
suspend fun onUpdated(profile: ProxyEntity)
|
||||
suspend fun onRemoved(groupId: Long, profileId: Long)
|
||||
}
|
||||
|
||||
interface RuleListener {
|
||||
suspend fun onAdd(rule: RuleEntity)
|
||||
suspend fun onUpdated(rule: RuleEntity)
|
||||
suspend fun onRemoved(ruleId: Long)
|
||||
suspend fun onCleared()
|
||||
}
|
||||
|
||||
private val listeners = ArrayList<Listener>()
|
||||
private val ruleListeners = ArrayList<RuleListener>()
|
||||
|
||||
suspend fun iterator(what: suspend Listener.() -> Unit) {
|
||||
synchronized(listeners) {
|
||||
listeners.toList()
|
||||
}.forEach { listener ->
|
||||
what(listener)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun ruleIterator(what: suspend RuleListener.() -> Unit) {
|
||||
val ruleListeners = synchronized(ruleListeners) {
|
||||
ruleListeners.toList()
|
||||
}
|
||||
for (listener in ruleListeners) {
|
||||
what(listener)
|
||||
}
|
||||
}
|
||||
|
||||
fun addListener(listener: Listener) {
|
||||
synchronized(listeners) {
|
||||
listeners.add(listener)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeListener(listener: Listener) {
|
||||
synchronized(listeners) {
|
||||
listeners.remove(listener)
|
||||
}
|
||||
}
|
||||
|
||||
fun addListener(listener: RuleListener) {
|
||||
synchronized(ruleListeners) {
|
||||
ruleListeners.add(listener)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeListener(listener: RuleListener) {
|
||||
synchronized(ruleListeners) {
|
||||
ruleListeners.remove(listener)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun createProfile(groupId: Long, bean: AbstractBean): ProxyEntity {
|
||||
bean.applyDefaultValues()
|
||||
|
||||
val profile = ProxyEntity(groupId = groupId).apply {
|
||||
id = 0
|
||||
putBean(bean)
|
||||
userOrder = SagerDatabase.proxyDao.nextOrder(groupId) ?: 1
|
||||
}
|
||||
profile.id = SagerDatabase.proxyDao.addProxy(profile)
|
||||
iterator { onAdd(profile) }
|
||||
return profile
|
||||
}
|
||||
|
||||
suspend fun updateProfile(profile: ProxyEntity) {
|
||||
SagerDatabase.proxyDao.updateProxy(profile)
|
||||
iterator { onUpdated(profile) }
|
||||
}
|
||||
|
||||
suspend fun updateProfile(profiles: List<ProxyEntity>) {
|
||||
SagerDatabase.proxyDao.updateProxy(profiles)
|
||||
profiles.forEach {
|
||||
iterator { onUpdated(it) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deleteProfile2(groupId: Long, profileId: Long) {
|
||||
if (SagerDatabase.proxyDao.deleteById(profileId) == 0) return
|
||||
if (DataStore.selectedProxy == profileId) {
|
||||
DataStore.selectedProxy = 0L
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deleteProfile(groupId: Long, profileId: Long) {
|
||||
if (SagerDatabase.proxyDao.deleteById(profileId) == 0) return
|
||||
if (DataStore.selectedProxy == profileId) {
|
||||
DataStore.selectedProxy = 0L
|
||||
}
|
||||
iterator { onRemoved(groupId, profileId) }
|
||||
if (SagerDatabase.proxyDao.countByGroup(groupId) > 1) {
|
||||
GroupManager.rearrange(groupId)
|
||||
}
|
||||
}
|
||||
|
||||
fun getProfile(profileId: Long): ProxyEntity? {
|
||||
if (profileId == 0L) return null
|
||||
return try {
|
||||
SagerDatabase.proxyDao.getById(profileId)
|
||||
} catch (ex: SQLiteCantOpenDatabaseException) {
|
||||
throw IOException(ex)
|
||||
} catch (ex: SQLException) {
|
||||
Logs.w(ex)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun getProfiles(profileIds: List<Long>): List<ProxyEntity> {
|
||||
if (profileIds.isEmpty()) return listOf()
|
||||
return try {
|
||||
SagerDatabase.proxyDao.getEntities(profileIds)
|
||||
} catch (ex: SQLiteCantOpenDatabaseException) {
|
||||
throw IOException(ex)
|
||||
} catch (ex: SQLException) {
|
||||
Logs.w(ex)
|
||||
listOf()
|
||||
}
|
||||
}
|
||||
|
||||
// postUpdate: post to listeners, don't change the DB
|
||||
|
||||
suspend fun postUpdate(profileId: Long) {
|
||||
postUpdate(getProfile(profileId) ?: return)
|
||||
}
|
||||
|
||||
suspend fun postUpdate(profile: ProxyEntity) {
|
||||
iterator { onUpdated(profile) }
|
||||
}
|
||||
|
||||
suspend fun postUpdate(data: TrafficData) {
|
||||
iterator { onUpdated(data) }
|
||||
}
|
||||
|
||||
suspend fun createRule(rule: RuleEntity, post: Boolean = true): RuleEntity {
|
||||
rule.userOrder = SagerDatabase.rulesDao.nextOrder() ?: 1
|
||||
rule.id = SagerDatabase.rulesDao.createRule(rule)
|
||||
if (post) {
|
||||
ruleIterator { onAdd(rule) }
|
||||
}
|
||||
return rule
|
||||
}
|
||||
|
||||
suspend fun updateRule(rule: RuleEntity) {
|
||||
SagerDatabase.rulesDao.updateRule(rule)
|
||||
ruleIterator { onUpdated(rule) }
|
||||
}
|
||||
|
||||
suspend fun deleteRule(ruleId: Long) {
|
||||
SagerDatabase.rulesDao.deleteById(ruleId)
|
||||
ruleIterator { onRemoved(ruleId) }
|
||||
}
|
||||
|
||||
suspend fun deleteRules(rules: List<RuleEntity>) {
|
||||
SagerDatabase.rulesDao.deleteRules(rules)
|
||||
ruleIterator {
|
||||
rules.forEach {
|
||||
onRemoved(it.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getRules(): List<RuleEntity> {
|
||||
var rules = SagerDatabase.rulesDao.allRules()
|
||||
if (rules.isEmpty() && !DataStore.rulesFirstCreate) {
|
||||
DataStore.rulesFirstCreate = true
|
||||
createRule(
|
||||
RuleEntity(
|
||||
name = app.getString(R.string.route_opt_block_quic),
|
||||
port = "443",
|
||||
network = "udp",
|
||||
outbound = -2
|
||||
)
|
||||
)
|
||||
createRule(
|
||||
RuleEntity(
|
||||
name = app.getString(R.string.route_opt_block_ads),
|
||||
domains = "geosite:category-ads-all",
|
||||
outbound = -2
|
||||
)
|
||||
)
|
||||
createRule(
|
||||
RuleEntity(
|
||||
name = app.getString(R.string.route_opt_block_analysis),
|
||||
domains = app.assets.open("analysis.txt").use {
|
||||
it.bufferedReader()
|
||||
.readLines()
|
||||
.filter { it.isNotBlank() }
|
||||
.joinToString("\n")
|
||||
},
|
||||
outbound = -2,
|
||||
)
|
||||
)
|
||||
var country = Locale.getDefault().country.lowercase()
|
||||
var displayCountry = Locale.getDefault().displayCountry
|
||||
if (country in arrayOf(
|
||||
"ir"
|
||||
)
|
||||
) {
|
||||
createRule(
|
||||
RuleEntity(
|
||||
name = app.getString(R.string.route_bypass_domain, displayCountry),
|
||||
domains = "domain:$country",
|
||||
outbound = -1
|
||||
), false
|
||||
)
|
||||
} else {
|
||||
country = Locale.CHINA.country.lowercase()
|
||||
displayCountry = Locale.CHINA.displayCountry
|
||||
createRule(
|
||||
RuleEntity(
|
||||
name = app.getString(R.string.route_play_store, displayCountry),
|
||||
domains = "domain:googleapis.cn",
|
||||
), false
|
||||
)
|
||||
createRule(
|
||||
RuleEntity(
|
||||
name = app.getString(R.string.route_bypass_domain, displayCountry),
|
||||
domains = "geosite:$country",
|
||||
outbound = -1
|
||||
), false
|
||||
)
|
||||
}
|
||||
createRule(
|
||||
RuleEntity(
|
||||
name = app.getString(R.string.route_bypass_ip, displayCountry),
|
||||
ip = "geoip:$country",
|
||||
outbound = -1
|
||||
), false
|
||||
)
|
||||
rules = SagerDatabase.rulesDao.allRules()
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,489 @@
|
||||
package io.nekohasekai.sagernet.database
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.room.*
|
||||
import com.esotericsoftware.kryo.io.ByteBufferInput
|
||||
import com.esotericsoftware.kryo.io.ByteBufferOutput
|
||||
import io.nekohasekai.sagernet.R
|
||||
import io.nekohasekai.sagernet.fmt.*
|
||||
import io.nekohasekai.sagernet.fmt.http.HttpBean
|
||||
import io.nekohasekai.sagernet.fmt.http.toUri
|
||||
import io.nekohasekai.sagernet.fmt.hysteria.*
|
||||
import io.nekohasekai.sagernet.fmt.internal.ChainBean
|
||||
import io.nekohasekai.sagernet.fmt.naive.NaiveBean
|
||||
import io.nekohasekai.sagernet.fmt.naive.buildNaiveConfig
|
||||
import io.nekohasekai.sagernet.fmt.naive.toUri
|
||||
import io.nekohasekai.sagernet.fmt.shadowsocks.*
|
||||
import io.nekohasekai.sagernet.fmt.socks.SOCKSBean
|
||||
import io.nekohasekai.sagernet.fmt.socks.toUri
|
||||
import io.nekohasekai.sagernet.fmt.ssh.SSHBean
|
||||
import io.nekohasekai.sagernet.fmt.trojan.TrojanBean
|
||||
import io.nekohasekai.sagernet.fmt.trojan.toUri
|
||||
import io.nekohasekai.sagernet.fmt.trojan_go.TrojanGoBean
|
||||
import io.nekohasekai.sagernet.fmt.trojan_go.buildTrojanGoConfig
|
||||
import io.nekohasekai.sagernet.fmt.trojan_go.toUri
|
||||
import io.nekohasekai.sagernet.fmt.tuic.TuicBean
|
||||
import io.nekohasekai.sagernet.fmt.tuic.buildTuicConfig
|
||||
import io.nekohasekai.sagernet.fmt.v2ray.*
|
||||
import io.nekohasekai.sagernet.fmt.wireguard.WireGuardBean
|
||||
import io.nekohasekai.sagernet.ktx.app
|
||||
import io.nekohasekai.sagernet.ktx.applyDefaultValues
|
||||
import io.nekohasekai.sagernet.ui.profile.*
|
||||
import moe.matsuri.nb4a.Protocols
|
||||
import moe.matsuri.nb4a.proxy.neko.*
|
||||
import moe.matsuri.nb4a.proxy.config.ConfigBean
|
||||
import moe.matsuri.nb4a.proxy.config.ConfigSettingActivity
|
||||
import moe.matsuri.nb4a.proxy.neko.NekoBean
|
||||
import moe.matsuri.nb4a.proxy.neko.NekoSettingActivity
|
||||
import moe.matsuri.nb4a.proxy.neko.haveStandardLink
|
||||
import moe.matsuri.nb4a.proxy.neko.shareLink
|
||||
|
||||
@Entity(
|
||||
tableName = "proxy_entities", indices = [Index("groupId", name = "groupId")]
|
||||
)
|
||||
data class ProxyEntity(
|
||||
@PrimaryKey(autoGenerate = true) var id: Long = 0L,
|
||||
var groupId: Long = 0L,
|
||||
var type: Int = 0,
|
||||
var userOrder: Long = 0L,
|
||||
var tx: Long = 0L,
|
||||
var rx: Long = 0L,
|
||||
var status: Int = 0,
|
||||
var ping: Int = 0,
|
||||
var uuid: String = "",
|
||||
var error: String? = null,
|
||||
var socksBean: SOCKSBean? = null,
|
||||
var httpBean: HttpBean? = null,
|
||||
var ssBean: ShadowsocksBean? = null,
|
||||
var vmessBean: VMessBean? = null,
|
||||
var trojanBean: TrojanBean? = null,
|
||||
var trojanGoBean: TrojanGoBean? = null,
|
||||
var naiveBean: NaiveBean? = null,
|
||||
var hysteriaBean: HysteriaBean? = null,
|
||||
var tuicBean: TuicBean? = null,
|
||||
var sshBean: SSHBean? = null,
|
||||
var wgBean: WireGuardBean? = null,
|
||||
var chainBean: ChainBean? = null,
|
||||
var nekoBean: NekoBean? = null,
|
||||
var configBean: ConfigBean? = null,
|
||||
) : Serializable() {
|
||||
|
||||
companion object {
|
||||
const val TYPE_SOCKS = 0
|
||||
const val TYPE_HTTP = 1
|
||||
const val TYPE_SS = 2
|
||||
const val TYPE_VMESS = 4
|
||||
|
||||
const val TYPE_TROJAN = 6
|
||||
const val TYPE_TROJAN_GO = 7
|
||||
const val TYPE_NAIVE = 9
|
||||
const val TYPE_HYSTERIA = 15
|
||||
|
||||
const val TYPE_SSH = 17
|
||||
const val TYPE_WG = 18
|
||||
|
||||
const val TYPE_TUIC = 20
|
||||
|
||||
const val TYPE_CONFIG = 998
|
||||
const val TYPE_NEKO = 999
|
||||
|
||||
const val TYPE_CHAIN = 8
|
||||
|
||||
val chainName by lazy { app.getString(R.string.proxy_chain) }
|
||||
|
||||
private val placeHolderBean = SOCKSBean().applyDefaultValues()
|
||||
|
||||
@JvmField
|
||||
val CREATOR = object : Serializable.CREATOR<ProxyEntity>() {
|
||||
|
||||
override fun newInstance(): ProxyEntity {
|
||||
return ProxyEntity()
|
||||
}
|
||||
|
||||
override fun newArray(size: Int): Array<ProxyEntity?> {
|
||||
return arrayOfNulls(size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Ignore
|
||||
@Transient
|
||||
var info: String = ""
|
||||
|
||||
@Ignore
|
||||
@Transient
|
||||
var dirty: Boolean = false
|
||||
|
||||
override fun initializeDefaultValues() {
|
||||
}
|
||||
|
||||
override fun serializeToBuffer(output: ByteBufferOutput) {
|
||||
output.writeInt(0)
|
||||
|
||||
output.writeLong(id)
|
||||
output.writeLong(groupId)
|
||||
output.writeInt(type)
|
||||
output.writeLong(userOrder)
|
||||
output.writeLong(tx)
|
||||
output.writeLong(rx)
|
||||
output.writeInt(status)
|
||||
output.writeInt(ping)
|
||||
output.writeString(uuid)
|
||||
output.writeString(error)
|
||||
|
||||
val data = KryoConverters.serialize(requireBean())
|
||||
output.writeVarInt(data.size, true)
|
||||
output.writeBytes(data)
|
||||
|
||||
output.writeBoolean(dirty)
|
||||
}
|
||||
|
||||
override fun deserializeFromBuffer(input: ByteBufferInput) {
|
||||
val version = input.readInt()
|
||||
|
||||
id = input.readLong()
|
||||
groupId = input.readLong()
|
||||
type = input.readInt()
|
||||
userOrder = input.readLong()
|
||||
tx = input.readLong()
|
||||
rx = input.readLong()
|
||||
status = input.readInt()
|
||||
ping = input.readInt()
|
||||
uuid = input.readString()
|
||||
error = input.readString()
|
||||
putByteArray(input.readBytes(input.readVarInt(true)))
|
||||
|
||||
dirty = input.readBoolean()
|
||||
}
|
||||
|
||||
|
||||
fun putByteArray(byteArray: ByteArray) {
|
||||
when (type) {
|
||||
TYPE_SOCKS -> socksBean = KryoConverters.socksDeserialize(byteArray)
|
||||
TYPE_HTTP -> httpBean = KryoConverters.httpDeserialize(byteArray)
|
||||
TYPE_SS -> ssBean = KryoConverters.shadowsocksDeserialize(byteArray)
|
||||
TYPE_VMESS -> vmessBean = KryoConverters.vmessDeserialize(byteArray)
|
||||
TYPE_TROJAN -> trojanBean = KryoConverters.trojanDeserialize(byteArray)
|
||||
TYPE_TROJAN_GO -> trojanGoBean = KryoConverters.trojanGoDeserialize(byteArray)
|
||||
TYPE_NAIVE -> naiveBean = KryoConverters.naiveDeserialize(byteArray)
|
||||
TYPE_HYSTERIA -> hysteriaBean = KryoConverters.hysteriaDeserialize(byteArray)
|
||||
TYPE_SSH -> sshBean = KryoConverters.sshDeserialize(byteArray)
|
||||
TYPE_WG -> wgBean = KryoConverters.wireguardDeserialize(byteArray)
|
||||
TYPE_TUIC -> tuicBean = KryoConverters.tuicDeserialize(byteArray)
|
||||
TYPE_CHAIN -> chainBean = KryoConverters.chainDeserialize(byteArray)
|
||||
TYPE_NEKO -> nekoBean = KryoConverters.nekoDeserialize(byteArray)
|
||||
TYPE_CONFIG -> configBean = KryoConverters.configDeserialize(byteArray)
|
||||
}
|
||||
}
|
||||
|
||||
fun displayType() = when (type) {
|
||||
TYPE_SOCKS -> socksBean!!.protocolName()
|
||||
TYPE_HTTP -> if (httpBean!!.isTLS()) "HTTPS" else "HTTP"
|
||||
TYPE_SS -> "Shadowsocks"
|
||||
TYPE_VMESS -> if (vmessBean!!.isVLESS) "VLESS" else "VMess"
|
||||
TYPE_TROJAN -> "Trojan"
|
||||
TYPE_TROJAN_GO -> "Trojan-Go"
|
||||
TYPE_NAIVE -> "Naïve"
|
||||
TYPE_HYSTERIA -> "Hysteria"
|
||||
TYPE_SSH -> "SSH"
|
||||
TYPE_WG -> "WireGuard"
|
||||
TYPE_TUIC -> "TUIC"
|
||||
TYPE_CHAIN -> chainName
|
||||
TYPE_NEKO -> nekoBean!!.displayType()
|
||||
TYPE_CONFIG -> configBean!!.displayType()
|
||||
else -> "Undefined type $type"
|
||||
}
|
||||
|
||||
fun displayName() = requireBean().displayName()
|
||||
fun displayAddress() = requireBean().displayAddress()
|
||||
|
||||
fun requireBean(): AbstractBean {
|
||||
return when (type) {
|
||||
TYPE_SOCKS -> socksBean
|
||||
TYPE_HTTP -> httpBean
|
||||
TYPE_SS -> ssBean
|
||||
TYPE_VMESS -> vmessBean
|
||||
TYPE_TROJAN -> trojanBean
|
||||
TYPE_TROJAN_GO -> trojanGoBean
|
||||
TYPE_NAIVE -> naiveBean
|
||||
TYPE_HYSTERIA -> hysteriaBean
|
||||
TYPE_SSH -> sshBean
|
||||
TYPE_WG -> wgBean
|
||||
TYPE_TUIC -> tuicBean
|
||||
TYPE_CHAIN -> chainBean
|
||||
TYPE_NEKO -> nekoBean
|
||||
TYPE_CONFIG -> configBean
|
||||
else -> error("Undefined type $type")
|
||||
} ?: error("Null ${displayType()} profile")
|
||||
}
|
||||
|
||||
fun haveLink(): Boolean {
|
||||
return when (type) {
|
||||
TYPE_CHAIN -> false
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
|
||||
fun haveStandardLink(): Boolean {
|
||||
return when (requireBean()) {
|
||||
is TuicBean -> false
|
||||
is SSHBean -> false
|
||||
is WireGuardBean -> false
|
||||
is NekoBean -> nekoBean!!.haveStandardLink()
|
||||
is ConfigBean -> false
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
|
||||
fun toLink(compact: Boolean = false): String? = with(requireBean()) {
|
||||
when (this) {
|
||||
is SOCKSBean -> toUri()
|
||||
is HttpBean -> toUri()
|
||||
is ShadowsocksBean -> toUri()
|
||||
is VMessBean -> if (compact) toV2rayN() else toUri()
|
||||
is TrojanBean -> toUri()
|
||||
is TrojanGoBean -> toUri()
|
||||
is NaiveBean -> toUri()
|
||||
is HysteriaBean -> toUri()
|
||||
is SSHBean -> toUniversalLink()
|
||||
is WireGuardBean -> toUniversalLink()
|
||||
is TuicBean -> toUniversalLink()
|
||||
is ConfigBean -> toUniversalLink()
|
||||
is NekoBean -> shareLink()
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun exportConfig(): Pair<String, String> {
|
||||
var name = "${requireBean().displayName()}.json"
|
||||
|
||||
return with(requireBean()) {
|
||||
StringBuilder().apply {
|
||||
val config = buildConfig(this@ProxyEntity)
|
||||
append(config.config)
|
||||
|
||||
if (!config.externalIndex.all { it.chain.isEmpty() }) {
|
||||
name = "profiles.txt"
|
||||
}
|
||||
|
||||
for ((chain) in config.externalIndex) {
|
||||
chain.entries.forEachIndexed { index, (port, profile) ->
|
||||
when (val bean = profile.requireBean()) {
|
||||
is TrojanGoBean -> {
|
||||
append("\n\n")
|
||||
append(bean.buildTrojanGoConfig(port))
|
||||
}
|
||||
is NaiveBean -> {
|
||||
append("\n\n")
|
||||
append(bean.buildNaiveConfig(port))
|
||||
}
|
||||
is HysteriaBean -> {
|
||||
append("\n\n")
|
||||
append(bean.buildHysteriaConfig(port, null))
|
||||
}
|
||||
is TuicBean -> {
|
||||
append("\n\n")
|
||||
append(bean.buildTuicConfig(port, null))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.toString()
|
||||
} to name
|
||||
}
|
||||
|
||||
fun needExternal(): Boolean {
|
||||
return when (type) {
|
||||
TYPE_TROJAN_GO -> true
|
||||
TYPE_NAIVE -> true
|
||||
TYPE_HYSTERIA -> !hysteriaBean!!.canUseSingBox()
|
||||
TYPE_TUIC -> true
|
||||
TYPE_NEKO -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
fun isV2RayNetworkTcp(): Boolean {
|
||||
val bean = requireBean() as StandardV2RayBean
|
||||
return when (bean.type) {
|
||||
"tcp", "ws", "http" -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
fun needCoreMux(): Boolean {
|
||||
return when (type) {
|
||||
TYPE_VMESS -> isV2RayNetworkTcp() && Protocols.shouldEnableMux("vmess")
|
||||
TYPE_TROJAN -> isV2RayNetworkTcp() && Protocols.shouldEnableMux("trojan")
|
||||
TYPE_SS -> !ssBean!!.sUoT && Protocols.shouldEnableMux("shadowsocks")
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
fun putBean(bean: AbstractBean): ProxyEntity {
|
||||
socksBean = null
|
||||
httpBean = null
|
||||
ssBean = null
|
||||
vmessBean = null
|
||||
trojanBean = null
|
||||
trojanGoBean = null
|
||||
naiveBean = null
|
||||
hysteriaBean = null
|
||||
sshBean = null
|
||||
wgBean = null
|
||||
tuicBean = null
|
||||
chainBean = null
|
||||
configBean = null
|
||||
nekoBean = null
|
||||
|
||||
when (bean) {
|
||||
is SOCKSBean -> {
|
||||
type = TYPE_SOCKS
|
||||
socksBean = bean
|
||||
}
|
||||
is HttpBean -> {
|
||||
type = TYPE_HTTP
|
||||
httpBean = bean
|
||||
}
|
||||
is ShadowsocksBean -> {
|
||||
type = TYPE_SS
|
||||
ssBean = bean
|
||||
}
|
||||
is VMessBean -> {
|
||||
type = TYPE_VMESS
|
||||
vmessBean = bean
|
||||
}
|
||||
is TrojanBean -> {
|
||||
type = TYPE_TROJAN
|
||||
trojanBean = bean
|
||||
}
|
||||
is TrojanGoBean -> {
|
||||
type = TYPE_TROJAN_GO
|
||||
trojanGoBean = bean
|
||||
}
|
||||
is NaiveBean -> {
|
||||
type = TYPE_NAIVE
|
||||
naiveBean = bean
|
||||
}
|
||||
is HysteriaBean -> {
|
||||
type = TYPE_HYSTERIA
|
||||
hysteriaBean = bean
|
||||
}
|
||||
is SSHBean -> {
|
||||
type = TYPE_SSH
|
||||
sshBean = bean
|
||||
}
|
||||
is WireGuardBean -> {
|
||||
type = TYPE_WG
|
||||
wgBean = bean
|
||||
}
|
||||
is TuicBean -> {
|
||||
type = TYPE_TUIC
|
||||
tuicBean = bean
|
||||
}
|
||||
is ChainBean -> {
|
||||
type = TYPE_CHAIN
|
||||
chainBean = bean
|
||||
}
|
||||
is NekoBean -> {
|
||||
type = TYPE_NEKO
|
||||
nekoBean = bean
|
||||
}
|
||||
is ConfigBean -> {
|
||||
type = TYPE_CONFIG
|
||||
configBean = bean
|
||||
}
|
||||
else -> error("Undefined type $type")
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
fun settingIntent(ctx: Context, isSubscription: Boolean): Intent {
|
||||
return Intent(
|
||||
ctx, when (type) {
|
||||
TYPE_SOCKS -> SocksSettingsActivity::class.java
|
||||
TYPE_HTTP -> HttpSettingsActivity::class.java
|
||||
TYPE_SS -> ShadowsocksSettingsActivity::class.java
|
||||
TYPE_VMESS -> VMessSettingsActivity::class.java
|
||||
TYPE_TROJAN -> TrojanSettingsActivity::class.java
|
||||
TYPE_TROJAN_GO -> TrojanGoSettingsActivity::class.java
|
||||
TYPE_NAIVE -> NaiveSettingsActivity::class.java
|
||||
TYPE_HYSTERIA -> HysteriaSettingsActivity::class.java
|
||||
TYPE_SSH -> SSHSettingsActivity::class.java
|
||||
TYPE_WG -> WireGuardSettingsActivity::class.java
|
||||
TYPE_TUIC -> TuicSettingsActivity::class.java
|
||||
TYPE_CHAIN -> ChainSettingsActivity::class.java
|
||||
TYPE_NEKO -> NekoSettingActivity::class.java
|
||||
TYPE_CONFIG -> ConfigSettingActivity::class.java
|
||||
else -> throw IllegalArgumentException()
|
||||
}
|
||||
).apply {
|
||||
putExtra(ProfileSettingsActivity.EXTRA_PROFILE_ID, id)
|
||||
putExtra(ProfileSettingsActivity.EXTRA_IS_SUBSCRIPTION, isSubscription)
|
||||
}
|
||||
}
|
||||
|
||||
@androidx.room.Dao
|
||||
interface Dao {
|
||||
|
||||
@Query("select * from proxy_entities")
|
||||
fun getAll(): List<ProxyEntity>
|
||||
|
||||
@Query("SELECT id FROM proxy_entities WHERE groupId = :groupId ORDER BY userOrder")
|
||||
fun getIdsByGroup(groupId: Long): List<Long>
|
||||
|
||||
@Query("SELECT * FROM proxy_entities WHERE groupId = :groupId ORDER BY userOrder")
|
||||
fun getByGroup(groupId: Long): List<ProxyEntity>
|
||||
|
||||
@Query("SELECT * FROM proxy_entities WHERE id in (:proxyIds)")
|
||||
fun getEntities(proxyIds: List<Long>): List<ProxyEntity>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM proxy_entities WHERE groupId = :groupId")
|
||||
fun countByGroup(groupId: Long): Long
|
||||
|
||||
@Query("SELECT MAX(userOrder) + 1 FROM proxy_entities WHERE groupId = :groupId")
|
||||
fun nextOrder(groupId: Long): Long?
|
||||
|
||||
@Query("SELECT * FROM proxy_entities WHERE id = :proxyId")
|
||||
fun getById(proxyId: Long): ProxyEntity?
|
||||
|
||||
@Query("DELETE FROM proxy_entities WHERE id IN (:proxyId)")
|
||||
fun deleteById(proxyId: Long): Int
|
||||
|
||||
@Query("DELETE FROM proxy_entities WHERE groupId = :groupId")
|
||||
fun deleteByGroup(groupId: Long)
|
||||
|
||||
@Query("DELETE FROM proxy_entities WHERE groupId in (:groupId)")
|
||||
fun deleteByGroup(groupId: LongArray)
|
||||
|
||||
@Delete
|
||||
fun deleteProxy(proxy: ProxyEntity): Int
|
||||
|
||||
@Delete
|
||||
fun deleteProxy(proxies: List<ProxyEntity>): Int
|
||||
|
||||
@Update
|
||||
fun updateProxy(proxy: ProxyEntity): Int
|
||||
|
||||
@Update
|
||||
fun updateProxy(proxies: List<ProxyEntity>): Int
|
||||
|
||||
@Insert
|
||||
fun addProxy(proxy: ProxyEntity): Long
|
||||
|
||||
@Insert
|
||||
fun insert(proxies: List<ProxyEntity>)
|
||||
|
||||
@Query("DELETE FROM proxy_entities WHERE groupId = :groupId")
|
||||
fun deleteAll(groupId: Long): Int
|
||||
|
||||
@Query("DELETE FROM proxy_entities")
|
||||
fun reset()
|
||||
|
||||
}
|
||||
|
||||
override fun describeContents(): Int {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
140
app/src/main/java/io/nekohasekai/sagernet/database/ProxyGroup.kt
Normal file
140
app/src/main/java/io/nekohasekai/sagernet/database/ProxyGroup.kt
Normal file
@ -0,0 +1,140 @@
|
||||
package io.nekohasekai.sagernet.database
|
||||
|
||||
import androidx.room.*
|
||||
import com.esotericsoftware.kryo.io.ByteBufferInput
|
||||
import com.esotericsoftware.kryo.io.ByteBufferOutput
|
||||
import io.nekohasekai.sagernet.GroupOrder
|
||||
import io.nekohasekai.sagernet.GroupType
|
||||
import io.nekohasekai.sagernet.R
|
||||
import io.nekohasekai.sagernet.fmt.Serializable
|
||||
import io.nekohasekai.sagernet.ktx.app
|
||||
import io.nekohasekai.sagernet.ktx.applyDefaultValues
|
||||
|
||||
@Entity(tableName = "proxy_groups")
|
||||
data class ProxyGroup(
|
||||
@PrimaryKey(autoGenerate = true) var id: Long = 0L,
|
||||
var userOrder: Long = 0L,
|
||||
var ungrouped: Boolean = false,
|
||||
var name: String? = null,
|
||||
var type: Int = GroupType.BASIC,
|
||||
var subscription: SubscriptionBean? = null,
|
||||
var order: Int = GroupOrder.ORIGIN,
|
||||
) : Serializable() {
|
||||
|
||||
@Transient
|
||||
var export = false
|
||||
|
||||
override fun initializeDefaultValues() {
|
||||
subscription?.applyDefaultValues()
|
||||
}
|
||||
|
||||
override fun serializeToBuffer(output: ByteBufferOutput) {
|
||||
if (export) {
|
||||
|
||||
output.writeInt(0)
|
||||
output.writeString(name)
|
||||
output.writeInt(type)
|
||||
val subscription = subscription!!
|
||||
subscription.serializeForShare(output)
|
||||
|
||||
} else {
|
||||
output.writeInt(0)
|
||||
output.writeLong(id)
|
||||
output.writeLong(userOrder)
|
||||
output.writeBoolean(ungrouped)
|
||||
output.writeString(name)
|
||||
output.writeInt(type)
|
||||
|
||||
if (type == GroupType.SUBSCRIPTION) {
|
||||
subscription?.serializeToBuffer(output)
|
||||
}
|
||||
output.writeInt(order)
|
||||
}
|
||||
}
|
||||
|
||||
override fun deserializeFromBuffer(input: ByteBufferInput) {
|
||||
if (export) {
|
||||
val version = input.readInt()
|
||||
|
||||
name = input.readString()
|
||||
type = input.readInt()
|
||||
val subscription = SubscriptionBean()
|
||||
this.subscription = subscription
|
||||
|
||||
subscription.deserializeFromShare(input)
|
||||
} else {
|
||||
val version = input.readInt()
|
||||
|
||||
id = input.readLong()
|
||||
userOrder = input.readLong()
|
||||
ungrouped = input.readBoolean()
|
||||
name = input.readString()
|
||||
type = input.readInt()
|
||||
|
||||
if (type == GroupType.SUBSCRIPTION) {
|
||||
val subscription = SubscriptionBean()
|
||||
this.subscription = subscription
|
||||
|
||||
subscription.deserializeFromBuffer(input)
|
||||
}
|
||||
order = input.readInt()
|
||||
}
|
||||
}
|
||||
|
||||
fun displayName(): String {
|
||||
return name.takeIf { !it.isNullOrBlank() } ?: app.getString(R.string.group_default)
|
||||
}
|
||||
|
||||
@androidx.room.Dao
|
||||
interface Dao {
|
||||
|
||||
@Query("SELECT * FROM proxy_groups ORDER BY userOrder")
|
||||
fun allGroups(): List<ProxyGroup>
|
||||
|
||||
@Query("SELECT * FROM proxy_groups WHERE type = ${GroupType.SUBSCRIPTION}")
|
||||
suspend fun subscriptions(): List<ProxyGroup>
|
||||
|
||||
@Query("SELECT MAX(userOrder) + 1 FROM proxy_groups")
|
||||
fun nextOrder(): Long?
|
||||
|
||||
@Query("SELECT * FROM proxy_groups WHERE id = :groupId")
|
||||
fun getById(groupId: Long): ProxyGroup?
|
||||
|
||||
@Query("DELETE FROM proxy_groups WHERE id = :groupId")
|
||||
fun deleteById(groupId: Long): Int
|
||||
|
||||
@Delete
|
||||
fun deleteGroup(group: ProxyGroup)
|
||||
|
||||
@Delete
|
||||
fun deleteGroup(groupList: List<ProxyGroup>)
|
||||
|
||||
@Insert
|
||||
fun createGroup(group: ProxyGroup): Long
|
||||
|
||||
@Update
|
||||
fun updateGroup(group: ProxyGroup)
|
||||
|
||||
@Query("DELETE FROM proxy_groups")
|
||||
fun reset()
|
||||
|
||||
@Insert
|
||||
fun insert(groupList: List<ProxyGroup>)
|
||||
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmField
|
||||
val CREATOR = object : Serializable.CREATOR<ProxyGroup>() {
|
||||
|
||||
override fun newInstance(): ProxyGroup {
|
||||
return ProxyGroup()
|
||||
}
|
||||
|
||||
override fun newArray(size: Int): Array<ProxyGroup?> {
|
||||
return arrayOfNulls(size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
106
app/src/main/java/io/nekohasekai/sagernet/database/RuleEntity.kt
Normal file
106
app/src/main/java/io/nekohasekai/sagernet/database/RuleEntity.kt
Normal file
@ -0,0 +1,106 @@
|
||||
package io.nekohasekai.sagernet.database
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.room.*
|
||||
import io.nekohasekai.sagernet.R
|
||||
import io.nekohasekai.sagernet.ktx.app
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Entity(tableName = "rules")
|
||||
@Parcelize
|
||||
data class RuleEntity(
|
||||
@PrimaryKey(autoGenerate = true) var id: Long = 0L,
|
||||
var name: String = "",
|
||||
var userOrder: Long = 0L,
|
||||
var enabled: Boolean = false,
|
||||
var domains: String = "",
|
||||
var ip: String = "",
|
||||
var port: String = "",
|
||||
var sourcePort: String = "",
|
||||
var network: String = "",
|
||||
var source: String = "",
|
||||
var protocol: String = "",
|
||||
var outbound: Long = 0,
|
||||
var packages: List<String> = listOf(),
|
||||
) : Parcelable {
|
||||
|
||||
fun displayName(): String {
|
||||
return name.takeIf { it.isNotBlank() } ?: "Rule $id"
|
||||
}
|
||||
|
||||
fun mkSummary(): String {
|
||||
var summary = ""
|
||||
if (domains.isNotBlank()) summary += "$domains\n"
|
||||
if (ip.isNotBlank()) summary += "$ip\n"
|
||||
if (source.isNotBlank()) summary += "source: $source\n"
|
||||
if (sourcePort.isNotBlank()) summary += "sourcePort: $sourcePort\n"
|
||||
if (port.isNotBlank()) summary += "port: $port\n"
|
||||
if (network.isNotBlank()) summary += "network: $network\n"
|
||||
if (protocol.isNotBlank()) summary += "protocol: $protocol\n"
|
||||
if (packages.isNotEmpty()) summary += app.getString(
|
||||
R.string.apps_message, packages.size
|
||||
) + "\n"
|
||||
val lines = summary.trim().split("\n")
|
||||
return if (lines.size > 3) {
|
||||
lines.subList(0, 3).joinToString("\n", postfix = "\n...")
|
||||
} else {
|
||||
summary.trim()
|
||||
}
|
||||
}
|
||||
|
||||
fun displayOutbound(): String {
|
||||
return when (outbound) {
|
||||
0L -> app.getString(R.string.route_proxy)
|
||||
-1L -> app.getString(R.string.route_bypass)
|
||||
-2L -> app.getString(R.string.route_block)
|
||||
else -> ProfileManager.getProfile(outbound)?.displayName()
|
||||
?: app.getString(R.string.route_proxy)
|
||||
}
|
||||
}
|
||||
|
||||
@androidx.room.Dao
|
||||
interface Dao {
|
||||
|
||||
@Query("SELECT * from rules WHERE (packages != '') AND enabled = 1")
|
||||
fun checkVpnNeeded(): List<RuleEntity>
|
||||
|
||||
@Query("SELECT * FROM rules ORDER BY userOrder")
|
||||
fun allRules(): List<RuleEntity>
|
||||
|
||||
@Query("SELECT * FROM rules WHERE enabled = :enabled ORDER BY userOrder")
|
||||
fun enabledRules(enabled: Boolean = true): List<RuleEntity>
|
||||
|
||||
@Query("SELECT MAX(userOrder) + 1 FROM rules")
|
||||
fun nextOrder(): Long?
|
||||
|
||||
@Query("SELECT * FROM rules WHERE id = :ruleId")
|
||||
fun getById(ruleId: Long): RuleEntity?
|
||||
|
||||
@Query("DELETE FROM rules WHERE id = :ruleId")
|
||||
fun deleteById(ruleId: Long): Int
|
||||
|
||||
@Delete
|
||||
fun deleteRule(rule: RuleEntity)
|
||||
|
||||
@Delete
|
||||
fun deleteRules(rules: List<RuleEntity>)
|
||||
|
||||
@Insert
|
||||
fun createRule(rule: RuleEntity): Long
|
||||
|
||||
@Update
|
||||
fun updateRule(rule: RuleEntity)
|
||||
|
||||
@Update
|
||||
fun updateRules(rules: List<RuleEntity>)
|
||||
|
||||
@Query("DELETE FROM rules")
|
||||
fun reset()
|
||||
|
||||
@Insert
|
||||
fun insert(rules: List<RuleEntity>)
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@ -0,0 +1,48 @@
|
||||
package io.nekohasekai.sagernet.database
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import dev.matrix.roomigrant.GenerateRoomMigrations
|
||||
import io.nekohasekai.sagernet.Key
|
||||
import io.nekohasekai.sagernet.SagerNet
|
||||
import io.nekohasekai.sagernet.fmt.KryoConverters
|
||||
import io.nekohasekai.sagernet.fmt.gson.GsonConverters
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Database(
|
||||
entities = [ProxyGroup::class, ProxyEntity::class, RuleEntity::class],
|
||||
version = 1
|
||||
)
|
||||
@TypeConverters(value = [KryoConverters::class, GsonConverters::class])
|
||||
@GenerateRoomMigrations
|
||||
abstract class SagerDatabase : RoomDatabase() {
|
||||
|
||||
companion object {
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
@Suppress("EXPERIMENTAL_API_USAGE")
|
||||
private val instance by lazy {
|
||||
SagerNet.application.getDatabasePath(Key.DB_PROFILE).parentFile?.mkdirs()
|
||||
Room.databaseBuilder(SagerNet.application, SagerDatabase::class.java, Key.DB_PROFILE)
|
||||
.addMigrations(*SagerDatabase_Migrations.build())
|
||||
.allowMainThreadQueries()
|
||||
.enableMultiInstanceInvalidation()
|
||||
.fallbackToDestructiveMigration()
|
||||
.setQueryExecutor { GlobalScope.launch { it.run() } }
|
||||
.build()
|
||||
}
|
||||
|
||||
val groupDao get() = instance.groupDao()
|
||||
val proxyDao get() = instance.proxyDao()
|
||||
val rulesDao get() = instance.rulesDao()
|
||||
|
||||
}
|
||||
|
||||
abstract fun groupDao(): ProxyGroup.Dao
|
||||
abstract fun proxyDao(): ProxyEntity.Dao
|
||||
abstract fun rulesDao(): RuleEntity.Dao
|
||||
|
||||
}
|
||||
@ -0,0 +1,138 @@
|
||||
package io.nekohasekai.sagernet.database;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.esotericsoftware.kryo.io.ByteBufferInput;
|
||||
import com.esotericsoftware.kryo.io.ByteBufferOutput;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import io.nekohasekai.sagernet.fmt.Serializable;
|
||||
|
||||
public class SubscriptionBean extends Serializable {
|
||||
|
||||
public Integer type;
|
||||
public String link;
|
||||
public String token;
|
||||
public Boolean forceResolve;
|
||||
public Boolean deduplication;
|
||||
public Boolean updateWhenConnectedOnly;
|
||||
public String customUserAgent;
|
||||
public Boolean autoUpdate;
|
||||
public Integer autoUpdateDelay;
|
||||
public Integer lastUpdated;
|
||||
|
||||
// SIP008
|
||||
|
||||
public Long bytesUsed;
|
||||
public Long bytesRemaining;
|
||||
|
||||
// Open Online Config
|
||||
|
||||
public String username;
|
||||
public Integer expiryDate;
|
||||
public List<String> protocols;
|
||||
|
||||
|
||||
// https://github.com/crossutility/Quantumult/blob/master/extra-subscription-feature.md
|
||||
|
||||
public String subscriptionUserinfo;
|
||||
|
||||
public SubscriptionBean() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void serializeToBuffer(ByteBufferOutput output) {
|
||||
output.writeInt(1);
|
||||
|
||||
output.writeInt(type);
|
||||
|
||||
output.writeString(link);
|
||||
|
||||
output.writeBoolean(forceResolve);
|
||||
output.writeBoolean(deduplication);
|
||||
output.writeBoolean(updateWhenConnectedOnly);
|
||||
output.writeString(customUserAgent);
|
||||
output.writeBoolean(autoUpdate);
|
||||
output.writeInt(autoUpdateDelay);
|
||||
output.writeInt(lastUpdated);
|
||||
|
||||
output.writeString(subscriptionUserinfo);
|
||||
}
|
||||
|
||||
public void serializeForShare(ByteBufferOutput output) {
|
||||
output.writeInt(0);
|
||||
|
||||
output.writeInt(type);
|
||||
|
||||
output.writeString(link);
|
||||
|
||||
output.writeBoolean(forceResolve);
|
||||
output.writeBoolean(deduplication);
|
||||
output.writeBoolean(updateWhenConnectedOnly);
|
||||
output.writeString(customUserAgent);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deserializeFromBuffer(ByteBufferInput input) {
|
||||
int version = input.readInt();
|
||||
|
||||
type = input.readInt();
|
||||
link = input.readString();
|
||||
forceResolve = input.readBoolean();
|
||||
deduplication = input.readBoolean();
|
||||
updateWhenConnectedOnly = input.readBoolean();
|
||||
customUserAgent = input.readString();
|
||||
autoUpdate = input.readBoolean();
|
||||
autoUpdateDelay = input.readInt();
|
||||
lastUpdated = input.readInt();
|
||||
subscriptionUserinfo = input.readString();
|
||||
}
|
||||
|
||||
public void deserializeFromShare(ByteBufferInput input) {
|
||||
int version = input.readInt();
|
||||
|
||||
type = input.readInt();
|
||||
link = input.readString();
|
||||
forceResolve = input.readBoolean();
|
||||
deduplication = input.readBoolean();
|
||||
updateWhenConnectedOnly = input.readBoolean();
|
||||
customUserAgent = input.readString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void initializeDefaultValues() {
|
||||
if (type == null) type = 0;
|
||||
if (link == null) link = "";
|
||||
if (token == null) token = "";
|
||||
if (forceResolve == null) forceResolve = false;
|
||||
if (deduplication == null) deduplication = false;
|
||||
if (updateWhenConnectedOnly == null) updateWhenConnectedOnly = false;
|
||||
if (customUserAgent == null) customUserAgent = "";
|
||||
if (autoUpdate == null) autoUpdate = false;
|
||||
if (autoUpdateDelay == null) autoUpdateDelay = 1440;
|
||||
if (lastUpdated == null) lastUpdated = 0;
|
||||
|
||||
if (bytesUsed == null) bytesUsed = 0L;
|
||||
if (bytesRemaining == null) bytesRemaining = 0L;
|
||||
|
||||
if (username == null) username = "";
|
||||
if (expiryDate == null) expiryDate = 0;
|
||||
if (protocols == null) protocols = new ArrayList<>();
|
||||
}
|
||||
|
||||
public static final Creator<SubscriptionBean> CREATOR = new CREATOR<SubscriptionBean>() {
|
||||
@NonNull
|
||||
@Override
|
||||
public SubscriptionBean newInstance() {
|
||||
return new SubscriptionBean();
|
||||
}
|
||||
|
||||
@Override
|
||||
public SubscriptionBean[] newArray(int size) {
|
||||
return new SubscriptionBean[size];
|
||||
}
|
||||
};
|
||||
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
package io.nekohasekai.sagernet.database.preference
|
||||
|
||||
import android.graphics.Typeface
|
||||
import android.text.InputFilter
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.widget.EditText
|
||||
import androidx.preference.EditTextPreference
|
||||
|
||||
object EditTextPreferenceModifiers {
|
||||
object Monospace : EditTextPreference.OnBindEditTextListener {
|
||||
override fun onBindEditText(editText: EditText) {
|
||||
editText.typeface = Typeface.MONOSPACE
|
||||
}
|
||||
}
|
||||
|
||||
object Hosts : EditTextPreference.OnBindEditTextListener {
|
||||
|
||||
override fun onBindEditText(editText: EditText) {
|
||||
editText.setHorizontallyScrolling(true)
|
||||
editText.setSelection(editText.text.length)
|
||||
}
|
||||
}
|
||||
|
||||
object Port : EditTextPreference.OnBindEditTextListener {
|
||||
private val portLengthFilter = arrayOf(InputFilter.LengthFilter(5))
|
||||
|
||||
override fun onBindEditText(editText: EditText) {
|
||||
editText.inputType = EditorInfo.TYPE_CLASS_NUMBER
|
||||
editText.filters = portLengthFilter
|
||||
editText.setSingleLine()
|
||||
editText.setSelection(editText.text.length)
|
||||
}
|
||||
}
|
||||
|
||||
object Number : EditTextPreference.OnBindEditTextListener {
|
||||
|
||||
override fun onBindEditText(editText: EditText) {
|
||||
editText.inputType = EditorInfo.TYPE_CLASS_NUMBER
|
||||
editText.setSingleLine()
|
||||
editText.setSelection(editText.text.length)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,170 @@
|
||||
package io.nekohasekai.sagernet.database.preference
|
||||
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import androidx.room.*
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
@Entity
|
||||
class KeyValuePair() : Parcelable {
|
||||
companion object {
|
||||
const val TYPE_UNINITIALIZED = 0
|
||||
const val TYPE_BOOLEAN = 1
|
||||
const val TYPE_FLOAT = 2
|
||||
|
||||
@Deprecated("Use TYPE_LONG.")
|
||||
const val TYPE_INT = 3
|
||||
const val TYPE_LONG = 4
|
||||
const val TYPE_STRING = 5
|
||||
const val TYPE_STRING_SET = 6
|
||||
|
||||
@JvmField
|
||||
val CREATOR = object : Parcelable.Creator<KeyValuePair> {
|
||||
override fun createFromParcel(parcel: Parcel): KeyValuePair {
|
||||
return KeyValuePair(parcel)
|
||||
}
|
||||
|
||||
override fun newArray(size: Int): Array<KeyValuePair?> {
|
||||
return arrayOfNulls(size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@androidx.room.Dao
|
||||
interface Dao {
|
||||
|
||||
@Query("SELECT * FROM `KeyValuePair`")
|
||||
fun all(): List<KeyValuePair>
|
||||
|
||||
@Query("SELECT * FROM `KeyValuePair` WHERE `key` = :key")
|
||||
operator fun get(key: String): KeyValuePair?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun put(value: KeyValuePair): Long
|
||||
|
||||
@Query("DELETE FROM `KeyValuePair` WHERE `key` = :key")
|
||||
fun delete(key: String): Int
|
||||
|
||||
@Query("DELETE FROM `KeyValuePair`")
|
||||
fun reset(): Int
|
||||
|
||||
@Insert
|
||||
fun insert(list: List<KeyValuePair>)
|
||||
}
|
||||
|
||||
@PrimaryKey
|
||||
var key: String = ""
|
||||
var valueType: Int = TYPE_UNINITIALIZED
|
||||
var value: ByteArray = ByteArray(0)
|
||||
|
||||
val boolean: Boolean?
|
||||
get() = if (valueType == TYPE_BOOLEAN) ByteBuffer.wrap(value).get() != 0.toByte() else null
|
||||
val float: Float?
|
||||
get() = if (valueType == TYPE_FLOAT) ByteBuffer.wrap(value).float else null
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
@Deprecated("Use long.", ReplaceWith("long"))
|
||||
val int: Int?
|
||||
get() = if (valueType == TYPE_INT) ByteBuffer.wrap(value).int else null
|
||||
val long: Long?
|
||||
get() = when (valueType) {
|
||||
@Suppress("DEPRECATION") TYPE_INT,
|
||||
-> ByteBuffer.wrap(value).int.toLong()
|
||||
TYPE_LONG -> ByteBuffer.wrap(value).long
|
||||
else -> null
|
||||
}
|
||||
val string: String?
|
||||
get() = if (valueType == TYPE_STRING) String(value) else null
|
||||
val stringSet: Set<String>?
|
||||
get() = if (valueType == TYPE_STRING_SET) {
|
||||
val buffer = ByteBuffer.wrap(value)
|
||||
val result = HashSet<String>()
|
||||
while (buffer.hasRemaining()) {
|
||||
val chArr = ByteArray(buffer.int)
|
||||
buffer.get(chArr)
|
||||
result.add(String(chArr))
|
||||
}
|
||||
result
|
||||
} else null
|
||||
|
||||
@Ignore
|
||||
constructor(key: String) : this() {
|
||||
this.key = key
|
||||
}
|
||||
|
||||
// putting null requires using DataStore
|
||||
fun put(value: Boolean): KeyValuePair {
|
||||
valueType = TYPE_BOOLEAN
|
||||
this.value = ByteBuffer.allocate(1).put((if (value) 1 else 0).toByte()).array()
|
||||
return this
|
||||
}
|
||||
|
||||
fun put(value: Float): KeyValuePair {
|
||||
valueType = TYPE_FLOAT
|
||||
this.value = ByteBuffer.allocate(4).putFloat(value).array()
|
||||
return this
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
@Deprecated("Use long.")
|
||||
fun put(value: Int): KeyValuePair {
|
||||
valueType = TYPE_INT
|
||||
this.value = ByteBuffer.allocate(4).putInt(value).array()
|
||||
return this
|
||||
}
|
||||
|
||||
fun put(value: Long): KeyValuePair {
|
||||
valueType = TYPE_LONG
|
||||
this.value = ByteBuffer.allocate(8).putLong(value).array()
|
||||
return this
|
||||
}
|
||||
|
||||
fun put(value: String): KeyValuePair {
|
||||
valueType = TYPE_STRING
|
||||
this.value = value.toByteArray()
|
||||
return this
|
||||
}
|
||||
|
||||
fun put(value: Set<String>): KeyValuePair {
|
||||
valueType = TYPE_STRING_SET
|
||||
val stream = ByteArrayOutputStream()
|
||||
val intBuffer = ByteBuffer.allocate(4)
|
||||
for (v in value) {
|
||||
intBuffer.rewind()
|
||||
stream.write(intBuffer.putInt(v.length).array())
|
||||
stream.write(v.toByteArray())
|
||||
}
|
||||
this.value = stream.toByteArray()
|
||||
return this
|
||||
}
|
||||
|
||||
@Suppress("IMPLICIT_CAST_TO_ANY")
|
||||
override fun toString(): String {
|
||||
return when (valueType) {
|
||||
TYPE_BOOLEAN -> boolean
|
||||
TYPE_FLOAT -> float
|
||||
TYPE_LONG -> long
|
||||
TYPE_STRING -> string
|
||||
TYPE_STRING_SET -> stringSet
|
||||
else -> null
|
||||
}?.toString() ?: "null"
|
||||
}
|
||||
|
||||
constructor(parcel: Parcel) : this() {
|
||||
key = parcel.readString()!!
|
||||
valueType = parcel.readInt()
|
||||
value = parcel.createByteArray()!!
|
||||
}
|
||||
|
||||
override fun writeToParcel(parcel: Parcel, flags: Int) {
|
||||
parcel.writeString(key)
|
||||
parcel.writeInt(valueType)
|
||||
parcel.writeByteArray(value)
|
||||
}
|
||||
|
||||
override fun describeContents(): Int {
|
||||
return 0
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
package io.nekohasekai.sagernet.database.preference
|
||||
|
||||
import androidx.preference.PreferenceDataStore
|
||||
|
||||
interface OnPreferenceDataStoreChangeListener {
|
||||
fun onPreferenceDataStoreChanged(store: PreferenceDataStore, key: String)
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
package io.nekohasekai.sagernet.database.preference
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import dev.matrix.roomigrant.GenerateRoomMigrations
|
||||
import io.nekohasekai.sagernet.Key
|
||||
import io.nekohasekai.sagernet.SagerNet
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Database(entities = [KeyValuePair::class], version = 1)
|
||||
@GenerateRoomMigrations
|
||||
abstract class PublicDatabase : RoomDatabase() {
|
||||
companion object {
|
||||
private val instance by lazy {
|
||||
SagerNet.application.getDatabasePath(Key.DB_PROFILE).parentFile?.mkdirs()
|
||||
Room.databaseBuilder(SagerNet.application, PublicDatabase::class.java, Key.DB_PUBLIC)
|
||||
.allowMainThreadQueries()
|
||||
.enableMultiInstanceInvalidation()
|
||||
.fallbackToDestructiveMigration()
|
||||
.setQueryExecutor { GlobalScope.launch { it.run() } }
|
||||
.build()
|
||||
}
|
||||
|
||||
val kvPairDao get() = instance.keyValuePairDao()
|
||||
}
|
||||
|
||||
abstract fun keyValuePairDao(): KeyValuePair.Dao
|
||||
|
||||
}
|
||||
@ -0,0 +1,90 @@
|
||||
package io.nekohasekai.sagernet.database.preference
|
||||
|
||||
import androidx.preference.PreferenceDataStore
|
||||
|
||||
@Suppress("MemberVisibilityCanBePrivate", "unused")
|
||||
open class RoomPreferenceDataStore(private val kvPairDao: KeyValuePair.Dao) :
|
||||
PreferenceDataStore() {
|
||||
|
||||
fun getBoolean(key: String) = kvPairDao[key]?.boolean
|
||||
fun getFloat(key: String) = kvPairDao[key]?.float
|
||||
fun getInt(key: String) = kvPairDao[key]?.long?.toInt()
|
||||
fun getLong(key: String) = kvPairDao[key]?.long
|
||||
fun getString(key: String) = kvPairDao[key]?.string
|
||||
fun getStringSet(key: String) = kvPairDao[key]?.stringSet
|
||||
fun reset() = kvPairDao.reset()
|
||||
|
||||
override fun getBoolean(key: String, defValue: Boolean) = getBoolean(key) ?: defValue
|
||||
override fun getFloat(key: String, defValue: Float) = getFloat(key) ?: defValue
|
||||
override fun getInt(key: String, defValue: Int) = getInt(key) ?: defValue
|
||||
override fun getLong(key: String, defValue: Long) = getLong(key) ?: defValue
|
||||
override fun getString(key: String, defValue: String?) = getString(key) ?: defValue
|
||||
override fun getStringSet(key: String, defValue: MutableSet<String>?) =
|
||||
getStringSet(key) ?: defValue
|
||||
|
||||
fun putBoolean(key: String, value: Boolean?) =
|
||||
if (value == null) remove(key) else putBoolean(key, value)
|
||||
|
||||
fun putFloat(key: String, value: Float?) =
|
||||
if (value == null) remove(key) else putFloat(key, value)
|
||||
|
||||
fun putInt(key: String, value: Int?) =
|
||||
if (value == null) remove(key) else putLong(key, value.toLong())
|
||||
|
||||
fun putLong(key: String, value: Long?) = if (value == null) remove(key) else putLong(key, value)
|
||||
override fun putBoolean(key: String, value: Boolean) {
|
||||
kvPairDao.put(KeyValuePair(key).put(value))
|
||||
fireChangeListener(key)
|
||||
}
|
||||
|
||||
override fun putFloat(key: String, value: Float) {
|
||||
kvPairDao.put(KeyValuePair(key).put(value))
|
||||
fireChangeListener(key)
|
||||
}
|
||||
|
||||
override fun putInt(key: String, value: Int) {
|
||||
kvPairDao.put(KeyValuePair(key).put(value.toLong()))
|
||||
fireChangeListener(key)
|
||||
}
|
||||
|
||||
override fun putLong(key: String, value: Long) {
|
||||
kvPairDao.put(KeyValuePair(key).put(value))
|
||||
fireChangeListener(key)
|
||||
}
|
||||
|
||||
override fun putString(key: String, value: String?) = if (value == null) remove(key) else {
|
||||
kvPairDao.put(KeyValuePair(key).put(value))
|
||||
fireChangeListener(key)
|
||||
}
|
||||
|
||||
override fun putStringSet(key: String, values: MutableSet<String>?) =
|
||||
if (values == null) remove(key) else {
|
||||
kvPairDao.put(KeyValuePair(key).put(values))
|
||||
fireChangeListener(key)
|
||||
}
|
||||
|
||||
fun remove(key: String) {
|
||||
kvPairDao.delete(key)
|
||||
fireChangeListener(key)
|
||||
}
|
||||
|
||||
private val listeners = HashSet<OnPreferenceDataStoreChangeListener>()
|
||||
private fun fireChangeListener(key: String) {
|
||||
val listeners = synchronized(listeners) {
|
||||
listeners.toList()
|
||||
}
|
||||
listeners.forEach { it.onPreferenceDataStoreChanged(this, key) }
|
||||
}
|
||||
|
||||
fun registerChangeListener(listener: OnPreferenceDataStoreChangeListener) {
|
||||
synchronized(listeners) {
|
||||
listeners.add(listener)
|
||||
}
|
||||
}
|
||||
|
||||
fun unregisterChangeListener(listener: OnPreferenceDataStoreChangeListener) {
|
||||
synchronized(listeners) {
|
||||
listeners.remove(listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
151
app/src/main/java/io/nekohasekai/sagernet/fmt/AbstractBean.java
Normal file
151
app/src/main/java/io/nekohasekai/sagernet/fmt/AbstractBean.java
Normal file
@ -0,0 +1,151 @@
|
||||
package io.nekohasekai.sagernet.fmt;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.esotericsoftware.kryo.io.ByteBufferInput;
|
||||
import com.esotericsoftware.kryo.io.ByteBufferOutput;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
import io.nekohasekai.sagernet.ktx.NetsKt;
|
||||
import moe.matsuri.nb4a.utils.JavaUtil;
|
||||
|
||||
public abstract class AbstractBean extends Serializable {
|
||||
|
||||
public String serverAddress;
|
||||
public Integer serverPort;
|
||||
|
||||
public String name;
|
||||
|
||||
//
|
||||
|
||||
public String customOutboundJson;
|
||||
public String customConfigJson;
|
||||
|
||||
//
|
||||
public transient String finalAddress;
|
||||
public transient int finalPort;
|
||||
|
||||
public String displayName() {
|
||||
if (JavaUtil.isNotBlank(name)) {
|
||||
return name;
|
||||
} else {
|
||||
return displayAddress();
|
||||
}
|
||||
}
|
||||
|
||||
public String displayAddress() {
|
||||
return NetsKt.wrapIPV6Host(serverAddress) + ":" + serverPort;
|
||||
}
|
||||
|
||||
public String network() {
|
||||
return "tcp,udp";
|
||||
}
|
||||
|
||||
public boolean canICMPing() {
|
||||
return true;
|
||||
}
|
||||
|
||||
public boolean canTCPing() {
|
||||
return true;
|
||||
}
|
||||
|
||||
public boolean canMapping() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void initializeDefaultValues() {
|
||||
if (JavaUtil.isNullOrBlank(serverAddress)) {
|
||||
serverAddress = "127.0.0.1";
|
||||
} else if (serverAddress.startsWith("[") && serverAddress.endsWith("]")) {
|
||||
serverAddress = NetsKt.unwrapIPV6Host(serverAddress);
|
||||
}
|
||||
if (serverPort == null) {
|
||||
serverPort = 1080;
|
||||
}
|
||||
if (name == null) name = "";
|
||||
|
||||
finalAddress = serverAddress;
|
||||
finalPort = serverPort;
|
||||
|
||||
if (customOutboundJson == null) customOutboundJson = "";
|
||||
if (customConfigJson == null) customConfigJson = "";
|
||||
}
|
||||
|
||||
|
||||
private transient boolean serializeWithoutName;
|
||||
|
||||
@Override
|
||||
public void serializeToBuffer(@NonNull ByteBufferOutput output) {
|
||||
serialize(output);
|
||||
|
||||
output.writeInt(1);
|
||||
if (!serializeWithoutName) {
|
||||
output.writeString(name);
|
||||
}
|
||||
output.writeString(customOutboundJson);
|
||||
output.writeString(customConfigJson);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deserializeFromBuffer(@NonNull ByteBufferInput input) {
|
||||
deserialize(input);
|
||||
|
||||
int extraVersion = input.readInt();
|
||||
|
||||
name = input.readString();
|
||||
customOutboundJson = input.readString();
|
||||
customConfigJson = input.readString();
|
||||
}
|
||||
|
||||
public void serialize(ByteBufferOutput output) {
|
||||
output.writeString(serverAddress);
|
||||
output.writeInt(serverPort);
|
||||
}
|
||||
|
||||
public void deserialize(ByteBufferInput input) {
|
||||
serverAddress = input.readString();
|
||||
serverPort = input.readInt();
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Override
|
||||
public abstract AbstractBean clone();
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
try {
|
||||
serializeWithoutName = true;
|
||||
((AbstractBean) o).serializeWithoutName = true;
|
||||
return Arrays.equals(KryoConverters.serialize(this), KryoConverters.serialize((AbstractBean) o));
|
||||
} finally {
|
||||
serializeWithoutName = false;
|
||||
((AbstractBean) o).serializeWithoutName = false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
try {
|
||||
serializeWithoutName = true;
|
||||
return Arrays.hashCode(KryoConverters.serialize(this));
|
||||
} finally {
|
||||
serializeWithoutName = false;
|
||||
}
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Override
|
||||
public String toString() {
|
||||
return getClass().getSimpleName() + " " + JavaUtil.gson.toJson(this);
|
||||
}
|
||||
|
||||
public void applyFeatureSettings(AbstractBean other) {
|
||||
}
|
||||
|
||||
}
|
||||
710
app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt
Normal file
710
app/src/main/java/io/nekohasekai/sagernet/fmt/ConfigBuilder.kt
Normal file
@ -0,0 +1,710 @@
|
||||
package io.nekohasekai.sagernet.fmt
|
||||
|
||||
import io.nekohasekai.sagernet.IPv6Mode
|
||||
import io.nekohasekai.sagernet.Key
|
||||
import io.nekohasekai.sagernet.bg.VpnService
|
||||
import io.nekohasekai.sagernet.database.DataStore
|
||||
import io.nekohasekai.sagernet.database.ProxyEntity
|
||||
import io.nekohasekai.sagernet.database.ProxyEntity.Companion.TYPE_CONFIG
|
||||
import io.nekohasekai.sagernet.database.SagerDatabase
|
||||
import io.nekohasekai.sagernet.fmt.ConfigBuildResult.IndexEntity
|
||||
import io.nekohasekai.sagernet.fmt.hysteria.HysteriaBean
|
||||
import io.nekohasekai.sagernet.fmt.hysteria.buildSingBoxOutboundHysteriaBean
|
||||
import io.nekohasekai.sagernet.fmt.hysteria.isMultiPort
|
||||
import io.nekohasekai.sagernet.fmt.internal.ChainBean
|
||||
import io.nekohasekai.sagernet.fmt.shadowsocks.ShadowsocksBean
|
||||
import io.nekohasekai.sagernet.fmt.shadowsocks.buildSingBoxOutboundShadowsocksBean
|
||||
import io.nekohasekai.sagernet.fmt.socks.SOCKSBean
|
||||
import io.nekohasekai.sagernet.fmt.socks.buildSingBoxOutboundSocksBean
|
||||
import io.nekohasekai.sagernet.fmt.ssh.SSHBean
|
||||
import io.nekohasekai.sagernet.fmt.ssh.buildSingBoxOutboundSSHBean
|
||||
import io.nekohasekai.sagernet.fmt.tuic.TuicBean
|
||||
import moe.matsuri.nb4a.SingBoxOptions.*
|
||||
import io.nekohasekai.sagernet.fmt.v2ray.StandardV2RayBean
|
||||
import io.nekohasekai.sagernet.fmt.v2ray.buildSingBoxOutboundStandardV2RayBean
|
||||
import io.nekohasekai.sagernet.fmt.wireguard.WireGuardBean
|
||||
import io.nekohasekai.sagernet.fmt.wireguard.buildSingBoxOutboundWireguardBean
|
||||
import io.nekohasekai.sagernet.ktx.isIpAddress
|
||||
import io.nekohasekai.sagernet.ktx.mkPort
|
||||
import io.nekohasekai.sagernet.utils.PackageCache
|
||||
import moe.matsuri.nb4a.DNS.applyDNSNetworkSettings
|
||||
import moe.matsuri.nb4a.DNS.makeSingBoxRule
|
||||
import moe.matsuri.nb4a.proxy.config.ConfigBean
|
||||
import moe.matsuri.nb4a.plugin.Plugins
|
||||
import moe.matsuri.nb4a.utils.JavaUtil.gson
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
|
||||
const val TAG_MIXED = "mixed-in"
|
||||
const val TAG_TRANS = "trans-in"
|
||||
|
||||
const val TAG_PROXY = "proxy"
|
||||
const val TAG_DIRECT = "direct"
|
||||
const val TAG_BYPASS = "bypass"
|
||||
const val TAG_BLOCK = "block"
|
||||
|
||||
const val TAG_DNS_IN = "dns-in"
|
||||
const val TAG_DNS_OUT = "dns-out"
|
||||
|
||||
const val LOCALHOST = "127.0.0.1"
|
||||
const val LOCAL_DNS_SERVER = "underlying://0.0.0.0"
|
||||
|
||||
class ConfigBuildResult(
|
||||
var config: String,
|
||||
var externalIndex: List<IndexEntity>,
|
||||
var outboundTags: List<String>,
|
||||
var outboundTagMain: String,
|
||||
var trafficMap: Map<String, ProxyEntity>,
|
||||
val alerts: List<Pair<Int, String>>,
|
||||
) {
|
||||
data class IndexEntity(var chain: LinkedHashMap<Int, ProxyEntity>)
|
||||
}
|
||||
|
||||
fun mergeJSON(j: String, to: MutableMap<String, Any>) {
|
||||
if (j.isNullOrBlank()) return
|
||||
val m = gson.fromJson(j, to.javaClass)
|
||||
m.forEach { (k, v) ->
|
||||
if (v is Map<*, *> && to[k] is Map<*, *>) {
|
||||
val currentMap = (to[k] as Map<*, *>).toMutableMap()
|
||||
currentMap += v
|
||||
to[k] = currentMap
|
||||
} else {
|
||||
to[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun buildConfig(
|
||||
proxy: ProxyEntity, forTest: Boolean = false
|
||||
): ConfigBuildResult {
|
||||
|
||||
if (proxy.type == TYPE_CONFIG) {
|
||||
val bean = proxy.requireBean() as ConfigBean
|
||||
if (bean.type == 0) {
|
||||
return ConfigBuildResult(
|
||||
bean.config,
|
||||
listOf(),
|
||||
listOf(TAG_PROXY), //
|
||||
TAG_PROXY, //
|
||||
mapOf(
|
||||
TAG_PROXY to proxy
|
||||
),
|
||||
listOf()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val outboundTags = ArrayList<String>()
|
||||
var outboundTagMain = TAG_BYPASS
|
||||
val trafficMap = HashMap<String, ProxyEntity>()
|
||||
val globalOutbounds = ArrayList<Long>()
|
||||
|
||||
fun ProxyEntity.resolveChain(): MutableList<ProxyEntity> {
|
||||
val bean = requireBean()
|
||||
if (bean is ChainBean) {
|
||||
val beans = SagerDatabase.proxyDao.getEntities(bean.proxies)
|
||||
val beansMap = beans.associateBy { it.id }
|
||||
val beanList = ArrayList<ProxyEntity>()
|
||||
for (proxyId in bean.proxies) {
|
||||
val item = beansMap[proxyId] ?: continue
|
||||
beanList.addAll(item.resolveChain())
|
||||
}
|
||||
return beanList.asReversed()
|
||||
}
|
||||
return mutableListOf(this)
|
||||
}
|
||||
|
||||
val proxies = proxy.resolveChain()
|
||||
val extraRules = if (forTest) listOf() else SagerDatabase.rulesDao.enabledRules()
|
||||
val extraProxies =
|
||||
if (forTest) mapOf() else SagerDatabase.proxyDao.getEntities(extraRules.mapNotNull { rule ->
|
||||
rule.outbound.takeIf { it > 0 && it != proxy.id }
|
||||
}.toHashSet().toList()).associate { it.id to it.resolveChain() }
|
||||
|
||||
val uidListDNSRemote = mutableListOf<Int>()
|
||||
val uidListDNSDirect = mutableListOf<Int>()
|
||||
val domainListDNSRemote = mutableListOf<String>()
|
||||
val domainListDNSDirect = mutableListOf<String>()
|
||||
val domainListDNSBlock = mutableListOf<String>()
|
||||
val bypassDNSBeans = hashSetOf<AbstractBean>()
|
||||
val isVPN = DataStore.serviceMode == Key.MODE_VPN
|
||||
val bind = if (!forTest && DataStore.allowAccess) "0.0.0.0" else LOCALHOST
|
||||
val remoteDns = DataStore.remoteDns.split("\n")
|
||||
.mapNotNull { dns -> dns.trim().takeIf { it.isNotBlank() && !it.startsWith("#") } }
|
||||
var directDNS = DataStore.directDns.split("\n")
|
||||
.mapNotNull { dns -> dns.trim().takeIf { it.isNotBlank() && !it.startsWith("#") } }
|
||||
val enableDnsRouting = DataStore.enableDnsRouting
|
||||
val useFakeDns = DataStore.enableFakeDns && !forTest && DataStore.ipv6Mode != IPv6Mode.ONLY
|
||||
val needSniff = DataStore.trafficSniffing
|
||||
val externalIndexMap = ArrayList<IndexEntity>()
|
||||
val requireTransproxy = if (forTest) false else DataStore.requireTransproxy
|
||||
val ipv6Mode = if (forTest) IPv6Mode.ENABLE else DataStore.ipv6Mode
|
||||
val resolveDestination = DataStore.resolveDestination
|
||||
val alerts = mutableListOf<Pair<Int, String>>()
|
||||
|
||||
var optionsToMerge: String = ""
|
||||
|
||||
return MyOptions().apply {
|
||||
if (!forTest && DataStore.enableClashAPI) experimental = ExperimentalOptions().apply {
|
||||
clash_api = ClashAPIOptions().apply {
|
||||
external_controller = "127.0.0.1:9090"
|
||||
external_ui = "../files/yacd"
|
||||
cache_file = "../cache/clash.db"
|
||||
}
|
||||
}
|
||||
|
||||
dns = DNSOptions().apply {
|
||||
// TODO nb4a hosts?
|
||||
// hosts = DataStore.hosts.split("\n")
|
||||
// .filter { it.isNotBlank() }
|
||||
// .associate { it.substringBefore(" ") to it.substringAfter(" ") }
|
||||
// .toMutableMap()
|
||||
|
||||
servers = mutableListOf()
|
||||
rules = mutableListOf()
|
||||
|
||||
when (ipv6Mode) {
|
||||
IPv6Mode.DISABLE -> {
|
||||
strategy = "ipv4_only"
|
||||
}
|
||||
IPv6Mode.ONLY -> {
|
||||
strategy = "ipv6_only"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inbounds = mutableListOf()
|
||||
|
||||
if (!forTest) {
|
||||
if (isVPN) inbounds.add(Inbound_TunOptions().apply {
|
||||
type = "tun"
|
||||
tag = "tun-in"
|
||||
stack = if (DataStore.tunImplementation == 1) "system" else "gvisor"
|
||||
sniff = needSniff
|
||||
endpoint_independent_nat = true
|
||||
when (ipv6Mode) {
|
||||
IPv6Mode.DISABLE -> {
|
||||
inet4_address = listOf(VpnService.PRIVATE_VLAN4_CLIENT + "/28")
|
||||
}
|
||||
IPv6Mode.ONLY -> {
|
||||
inet6_address = listOf(VpnService.PRIVATE_VLAN6_CLIENT + "/126")
|
||||
}
|
||||
else -> {
|
||||
inet4_address = listOf(VpnService.PRIVATE_VLAN4_CLIENT + "/28")
|
||||
inet6_address = listOf(VpnService.PRIVATE_VLAN6_CLIENT + "/126")
|
||||
}
|
||||
}
|
||||
})
|
||||
inbounds.add(Inbound_MixedOptions().apply {
|
||||
type = "mixed"
|
||||
tag = TAG_MIXED
|
||||
listen = bind
|
||||
listen_port = DataStore.mixedPort
|
||||
if (needSniff) {
|
||||
sniff = true
|
||||
// destOverride = when {
|
||||
// useFakeDns && !trafficSniffing -> listOf("fakedns")
|
||||
// useFakeDns -> listOf("fakedns", "http", "tls", "quic")
|
||||
// else -> listOf("http", "tls", "quic")
|
||||
// }
|
||||
// metadataOnly = useFakeDns && !trafficSniffing
|
||||
// routeOnly = true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (requireTransproxy) {
|
||||
if (DataStore.transproxyMode == 1) {
|
||||
inbounds.add(Inbound_TProxyOptions().apply {
|
||||
type = "tproxy"
|
||||
tag = TAG_TRANS
|
||||
listen = bind
|
||||
listen_port = DataStore.transproxyPort
|
||||
sniff = needSniff
|
||||
})
|
||||
} else {
|
||||
inbounds.add(Inbound_RedirectOptions().apply {
|
||||
type = "redirect"
|
||||
tag = TAG_TRANS
|
||||
listen = bind
|
||||
listen_port = DataStore.transproxyPort
|
||||
sniff = needSniff
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
outbounds = mutableListOf()
|
||||
|
||||
// init routing object
|
||||
route = RouteOptions().apply {
|
||||
auto_detect_interface = true
|
||||
rules = mutableListOf()
|
||||
}
|
||||
|
||||
// returns outbound tag
|
||||
fun buildChain(
|
||||
chainId: Long, profileList: List<ProxyEntity>
|
||||
): String {
|
||||
var currentOutbound = mutableMapOf<String, Any>()
|
||||
lateinit var pastOutbound: MutableMap<String, Any>
|
||||
lateinit var pastInboundTag: String
|
||||
var pastEntity: ProxyEntity? = null
|
||||
val externalChainMap = LinkedHashMap<Int, ProxyEntity>()
|
||||
externalIndexMap.add(IndexEntity(externalChainMap))
|
||||
val chainOutbounds = ArrayList<MutableMap<String, Any>>()
|
||||
|
||||
// chainTagOut: v2ray outbound tag for this chain
|
||||
var chainTagOut = ""
|
||||
var chainTag = "c-$chainId"
|
||||
var muxApplied = false
|
||||
|
||||
fun genDomainStrategy(noAsIs: Boolean): String {
|
||||
return when {
|
||||
!resolveDestination && !noAsIs -> ""
|
||||
ipv6Mode == IPv6Mode.DISABLE -> "ipv4_only"
|
||||
ipv6Mode == IPv6Mode.PREFER -> "prefer_ipv6"
|
||||
ipv6Mode == IPv6Mode.ONLY -> "ipv6_only"
|
||||
else -> "prefer_ipv4"
|
||||
}
|
||||
}
|
||||
|
||||
var currentDomainStrategy = genDomainStrategy(false)
|
||||
|
||||
profileList.forEachIndexed { index, proxyEntity ->
|
||||
val bean = proxyEntity.requireBean()
|
||||
|
||||
// tagOut: v2ray outbound tag for a profile
|
||||
// profile2 (in) (global) tag g-(id)
|
||||
// profile1 tag (chainTag)-(id)
|
||||
// profile0 (out) tag (chainTag)-(id) / single: "proxy"
|
||||
var tagOut = "$chainTag-${proxyEntity.id}"
|
||||
|
||||
// needGlobal: can only contain one?
|
||||
var needGlobal = false
|
||||
|
||||
// first profile set as global
|
||||
if (index == profileList.lastIndex) {
|
||||
needGlobal = true
|
||||
tagOut = "g-" + proxyEntity.id
|
||||
bypassDNSBeans += proxyEntity.requireBean()
|
||||
}
|
||||
|
||||
// last profile set as "proxy"
|
||||
if (chainId == 0L && index == 0) {
|
||||
tagOut = TAG_PROXY
|
||||
}
|
||||
|
||||
// chain rules
|
||||
if (index > 0) {
|
||||
// chain route/proxy rules
|
||||
if (pastEntity!!.needExternal()) {
|
||||
route.rules.add(Rule_DefaultOptions().apply {
|
||||
inbound = listOf(pastInboundTag)
|
||||
outbound = tagOut
|
||||
})
|
||||
} else {
|
||||
pastOutbound["detour"] = tagOut
|
||||
}
|
||||
} else {
|
||||
// index == 0 means last profile in chain / not chain
|
||||
chainTagOut = tagOut
|
||||
outboundTags.add(tagOut)
|
||||
if (chainId == 0L) outboundTagMain = tagOut
|
||||
}
|
||||
|
||||
if (needGlobal) {
|
||||
if (globalOutbounds.contains(proxyEntity.id)) {
|
||||
return@forEachIndexed
|
||||
}
|
||||
globalOutbounds.add(proxyEntity.id)
|
||||
}
|
||||
|
||||
// include g-xx
|
||||
trafficMap[tagOut] = proxyEntity
|
||||
|
||||
// Chain outbound
|
||||
if (proxyEntity.needExternal()) {
|
||||
val localPort = mkPort()
|
||||
externalChainMap[localPort] = proxyEntity
|
||||
currentOutbound = Outbound_SocksOptions().apply {
|
||||
type = "socks"
|
||||
server = LOCALHOST
|
||||
server_port = localPort
|
||||
}.asMap()
|
||||
} else {
|
||||
// internal outbound
|
||||
|
||||
currentOutbound = when (bean) {
|
||||
is ConfigBean ->
|
||||
gson.fromJson(bean.config, currentOutbound.javaClass)
|
||||
is StandardV2RayBean ->
|
||||
buildSingBoxOutboundStandardV2RayBean(bean).asMap()
|
||||
is HysteriaBean ->
|
||||
buildSingBoxOutboundHysteriaBean(bean).asMap()
|
||||
is SOCKSBean ->
|
||||
buildSingBoxOutboundSocksBean(bean).asMap()
|
||||
is ShadowsocksBean ->
|
||||
buildSingBoxOutboundShadowsocksBean(bean).asMap()
|
||||
is WireGuardBean ->
|
||||
buildSingBoxOutboundWireguardBean(bean).asMap()
|
||||
is SSHBean ->
|
||||
buildSingBoxOutboundSSHBean(bean).asMap()
|
||||
else -> throw IllegalStateException("can't reach")
|
||||
}
|
||||
|
||||
currentOutbound.apply {
|
||||
// TODO nb4a keepAliveInterval?
|
||||
// val keepAliveInterval = DataStore.tcpKeepAliveInterval
|
||||
// val needKeepAliveInterval = keepAliveInterval !in intArrayOf(0, 15)
|
||||
|
||||
if (!muxApplied && proxyEntity.needCoreMux()) {
|
||||
muxApplied = true
|
||||
currentOutbound["multiplex"] = MultiplexOptions().apply {
|
||||
enabled = true
|
||||
max_streams = DataStore.muxConcurrency
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// custom JSON merge
|
||||
if (bean.customOutboundJson.isNotBlank()) {
|
||||
mergeJSON(bean.customOutboundJson, currentOutbound)
|
||||
}
|
||||
if (index == 0 && bean.customConfigJson.isNotBlank()) {
|
||||
optionsToMerge = bean.customConfigJson
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
pastEntity?.requireBean()?.apply {
|
||||
// don't loopback
|
||||
if (currentDomainStrategy != "" && !serverAddress.isIpAddress()) {
|
||||
domainListDNSDirect.add("full:$serverAddress")
|
||||
}
|
||||
}
|
||||
if (forTest) {
|
||||
currentDomainStrategy = ""
|
||||
}
|
||||
|
||||
currentOutbound["tag"] = tagOut
|
||||
currentOutbound["domain_strategy"] = currentDomainStrategy
|
||||
|
||||
// External proxy need a dokodemo-door inbound to forward the traffic
|
||||
// For external proxy software, their traffic must goes to v2ray-core to use protected fd.
|
||||
if (bean.canMapping() && proxyEntity.needExternal()) {
|
||||
// With ss protect, don't use mapping
|
||||
var needExternal = true
|
||||
if (index == profileList.lastIndex) {
|
||||
val pluginId = when (bean) {
|
||||
is HysteriaBean -> "hysteria-plugin"
|
||||
is TuicBean -> "tuic-plugin"
|
||||
else -> ""
|
||||
}
|
||||
if (Plugins.isUsingMatsuriExe(pluginId)) {
|
||||
needExternal = false
|
||||
} else if (bean is HysteriaBean) {
|
||||
throw Exception("not supported hysteria-plugin (SagerNet)")
|
||||
}
|
||||
}
|
||||
if (needExternal) {
|
||||
val mappingPort = mkPort()
|
||||
bean.finalAddress = LOCALHOST
|
||||
bean.finalPort = mappingPort
|
||||
|
||||
inbounds.add(Inbound_DirectOptions().apply {
|
||||
type = "direct"
|
||||
listen = LOCALHOST
|
||||
listen_port = mappingPort
|
||||
tag = "$chainTag-mapping-${proxyEntity.id}"
|
||||
|
||||
override_address = bean.serverAddress
|
||||
override_port = bean.serverPort
|
||||
|
||||
pastInboundTag = tag
|
||||
|
||||
// no chain rule and not outbound, so need to set to direct
|
||||
if (index == profileList.lastIndex) {
|
||||
route.rules.add(Rule_DefaultOptions().apply {
|
||||
inbound = listOf(tag)
|
||||
outbound = TAG_DIRECT
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
outbounds.add(currentOutbound)
|
||||
chainOutbounds.add(currentOutbound)
|
||||
pastOutbound = currentOutbound
|
||||
pastEntity = proxyEntity
|
||||
}
|
||||
|
||||
return chainTagOut
|
||||
}
|
||||
|
||||
val tagProxy = buildChain(0, proxies)
|
||||
val tagMap = mutableMapOf<Long, String>()
|
||||
extraProxies.forEach { (key, entities) ->
|
||||
tagMap[key] = buildChain(key, entities)
|
||||
}
|
||||
|
||||
// apply user rules
|
||||
for (rule in extraRules) {
|
||||
val _uidList = rule.packages.map {
|
||||
PackageCache[it]?.takeIf { uid -> uid >= 1000 }
|
||||
}.toHashSet().filterNotNull()
|
||||
|
||||
if (rule.packages.isNotEmpty()) {
|
||||
if (!isVPN) {
|
||||
alerts.add(0 to rule.displayName())
|
||||
continue
|
||||
}
|
||||
}
|
||||
route.rules.add(Rule_DefaultOptions().apply {
|
||||
if (rule.packages.isNotEmpty()) {
|
||||
PackageCache.awaitLoadSync()
|
||||
user_id = _uidList
|
||||
}
|
||||
|
||||
var _domainList: List<String>? = null
|
||||
if (rule.domains.isNotBlank()) {
|
||||
_domainList = rule.domains.split("\n")
|
||||
makeSingBoxRule(_domainList, false)
|
||||
}
|
||||
if (rule.ip.isNotBlank()) {
|
||||
makeSingBoxRule(rule.ip.split("\n"), true)
|
||||
}
|
||||
if (rule.port.isNotBlank()) {
|
||||
port = rule.port.split("\n").map { it.toIntOrNull() ?: 0 }
|
||||
}
|
||||
if (rule.sourcePort.isNotBlank()) {
|
||||
source_port = rule.sourcePort.split("\n").map { it.toIntOrNull() ?: 0 }
|
||||
}
|
||||
if (rule.network.isNotBlank()) {
|
||||
network = rule.network
|
||||
}
|
||||
if (rule.source.isNotBlank()) {
|
||||
source_ip_cidr = rule.source.split("\n")
|
||||
}
|
||||
if (rule.protocol.isNotBlank()) {
|
||||
protocol = rule.protocol.split("\n")
|
||||
}
|
||||
|
||||
// also bypass lookup
|
||||
// cannot use other outbound profile to lookup...
|
||||
if (rule.outbound == -1L) {
|
||||
uidListDNSDirect += _uidList
|
||||
if (_domainList != null) domainListDNSDirect += _domainList
|
||||
} else if (rule.outbound == 0L) {
|
||||
uidListDNSRemote += _uidList
|
||||
if (_domainList != null) domainListDNSRemote += _domainList
|
||||
} else if (rule.outbound == -2L) {
|
||||
if (_domainList != null) domainListDNSBlock += _domainList
|
||||
}
|
||||
|
||||
outbound = when (val outId = rule.outbound) {
|
||||
0L -> tagProxy
|
||||
-1L -> TAG_BYPASS
|
||||
-2L -> TAG_BLOCK
|
||||
else -> if (outId == proxy.id) tagProxy else tagMap[outId]
|
||||
?: throw Exception("invalid rule")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for (freedom in arrayOf(TAG_DIRECT, TAG_BYPASS)) outbounds.add(Outbound().apply {
|
||||
tag = freedom
|
||||
type = "direct"
|
||||
}.asMap())
|
||||
|
||||
outbounds.add(Outbound().apply {
|
||||
tag = TAG_BLOCK
|
||||
type = "block"
|
||||
}.asMap())
|
||||
|
||||
if (!forTest) {
|
||||
inbounds.add(0, Inbound_DirectOptions().apply {
|
||||
type = "direct"
|
||||
tag = TAG_DNS_IN
|
||||
listen = bind
|
||||
listen_port = DataStore.localDNSPort
|
||||
override_address = if (!remoteDns.first().isIpAddress()) {
|
||||
"8.8.8.8"
|
||||
} else {
|
||||
remoteDns.first()
|
||||
}
|
||||
override_port = 53
|
||||
})
|
||||
|
||||
outbounds.add(Outbound().apply {
|
||||
type = "dns"
|
||||
tag = TAG_DNS_OUT
|
||||
}.asMap())
|
||||
}
|
||||
|
||||
if (DataStore.directDnsUseSystem) {
|
||||
// finally able to use "localDns" now...
|
||||
directDNS = listOf(LOCAL_DNS_SERVER)
|
||||
}
|
||||
|
||||
// routing for DNS server
|
||||
for (dns in remoteDns) {
|
||||
if (!dns.isIpAddress()) continue
|
||||
route.rules.add(Rule_DefaultOptions().apply {
|
||||
outbound = tagProxy
|
||||
ip_cidr = listOf(dns)
|
||||
})
|
||||
}
|
||||
|
||||
for (dns in directDNS) {
|
||||
if (!dns.isIpAddress()) continue
|
||||
route.rules.add(Rule_DefaultOptions().apply {
|
||||
outbound = TAG_DIRECT
|
||||
ip_cidr = listOf(dns)
|
||||
})
|
||||
}
|
||||
|
||||
// Bypass Lookup for the first profile
|
||||
bypassDNSBeans.forEach {
|
||||
var serverAddr = it.serverAddress
|
||||
if (it is HysteriaBean && it.isMultiPort()) {
|
||||
serverAddr = it.serverAddress.substringBeforeLast(":")
|
||||
}
|
||||
|
||||
if (!serverAddr.isIpAddress()) {
|
||||
domainListDNSDirect.add("full:${serverAddr}")
|
||||
}
|
||||
}
|
||||
|
||||
remoteDns.forEach {
|
||||
var address = it
|
||||
if (address.contains("://")) {
|
||||
address = address.substringAfter("://")
|
||||
}
|
||||
"https://$address".toHttpUrlOrNull()?.apply {
|
||||
if (!host.isIpAddress()) {
|
||||
domainListDNSDirect.add("full:$host")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// remote dns obj
|
||||
remoteDns.firstOrNull()?.apply {
|
||||
val d = this
|
||||
dns.servers.add(DNSServerOptions().apply {
|
||||
address = d
|
||||
tag = "dns-remote"
|
||||
address_resolver = "dns-direct"
|
||||
applyDNSNetworkSettings(false)
|
||||
})
|
||||
}
|
||||
|
||||
// add directDNS objects here
|
||||
directDNS.firstOrNull()?.apply {
|
||||
val d = this
|
||||
dns.servers.add(DNSServerOptions().apply {
|
||||
address = d
|
||||
tag = "dns-direct"
|
||||
detour = "direct"
|
||||
address_resolver = "dns-local"
|
||||
applyDNSNetworkSettings(true)
|
||||
})
|
||||
}
|
||||
dns.servers.add(DNSServerOptions().apply {
|
||||
address = LOCAL_DNS_SERVER
|
||||
tag = "dns-local"
|
||||
detour = "direct"
|
||||
})
|
||||
dns.servers.add(DNSServerOptions().apply {
|
||||
address = "rcode://success"
|
||||
tag = "dns-block"
|
||||
})
|
||||
|
||||
// dns object user rules
|
||||
if (enableDnsRouting) {
|
||||
if (domainListDNSRemote.isNotEmpty() || uidListDNSRemote.isNotEmpty()) {
|
||||
dns.rules.add(
|
||||
DNSRule_DefaultOptions().apply {
|
||||
makeSingBoxRule(domainListDNSRemote.toHashSet().toList())
|
||||
user_id = uidListDNSRemote.toHashSet().toList()
|
||||
server = "dns-remote"
|
||||
}
|
||||
)
|
||||
}
|
||||
if (domainListDNSDirect.isNotEmpty() || uidListDNSDirect.isNotEmpty()) {
|
||||
dns.rules.add(
|
||||
DNSRule_DefaultOptions().apply {
|
||||
makeSingBoxRule(domainListDNSDirect.toHashSet().toList())
|
||||
user_id = uidListDNSDirect.toHashSet().toList()
|
||||
server = "dns-direct"
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
if (domainListDNSBlock.isNotEmpty()) {
|
||||
dns.rules.add(
|
||||
DNSRule_DefaultOptions().apply {
|
||||
makeSingBoxRule(domainListDNSBlock.toHashSet().toList())
|
||||
server = "dns-block"
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Disable DNS for test
|
||||
if (forTest) {
|
||||
dns.servers.clear()
|
||||
dns.rules.clear()
|
||||
}
|
||||
|
||||
if (!forTest) {
|
||||
route.rules.add(Rule_DefaultOptions().apply {
|
||||
inbound = listOf(TAG_DNS_IN)
|
||||
outbound = TAG_DNS_OUT
|
||||
})
|
||||
route.rules.add(Rule_DefaultOptions().apply {
|
||||
port = listOf(53)
|
||||
outbound = TAG_DNS_OUT
|
||||
}) // TODO new mode use system dns?
|
||||
if (DataStore.bypassLan && DataStore.bypassLanInCoreOnly) {
|
||||
route.rules.add(Rule_DefaultOptions().apply {
|
||||
outbound = TAG_BYPASS
|
||||
geoip = listOf("private")
|
||||
})
|
||||
}
|
||||
// block mcast
|
||||
route.rules.add(Rule_DefaultOptions().apply {
|
||||
ip_cidr = listOf("224.0.0.0/3", "ff00::/8")
|
||||
source_ip_cidr = listOf("224.0.0.0/3", "ff00::/8")
|
||||
outbound = TAG_BLOCK
|
||||
})
|
||||
dns.rules.add(DNSRule_DefaultOptions().apply {
|
||||
domain_suffix = listOf(".arpa.", ".arpa")
|
||||
server = "dns-block"
|
||||
})
|
||||
}
|
||||
|
||||
// fakedns obj
|
||||
if (useFakeDns) {
|
||||
dns.servers.add(DNSServerOptions().apply {
|
||||
address = "fakedns://" + VpnService.FAKEDNS_VLAN4_CLIENT + "/15"
|
||||
tag = "dns-fake"
|
||||
strategy = "ipv4_only"
|
||||
})
|
||||
dns.rules.add(DNSRule_DefaultOptions().apply {
|
||||
inbound = listOf("tun-in")
|
||||
server = "dns-fake"
|
||||
})
|
||||
}
|
||||
}.let {
|
||||
ConfigBuildResult(
|
||||
gson.toJson(it.asMap().apply {
|
||||
mergeJSON(optionsToMerge, this)
|
||||
}),
|
||||
externalIndexMap,
|
||||
outboundTags,
|
||||
outboundTagMain,
|
||||
trafficMap,
|
||||
alerts
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,149 @@
|
||||
package io.nekohasekai.sagernet.fmt;
|
||||
|
||||
import androidx.room.TypeConverter;
|
||||
|
||||
import com.esotericsoftware.kryo.KryoException;
|
||||
import com.esotericsoftware.kryo.io.ByteBufferInput;
|
||||
import com.esotericsoftware.kryo.io.ByteBufferOutput;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
|
||||
import io.nekohasekai.sagernet.database.SubscriptionBean;
|
||||
import io.nekohasekai.sagernet.fmt.http.HttpBean;
|
||||
import io.nekohasekai.sagernet.fmt.hysteria.HysteriaBean;
|
||||
import io.nekohasekai.sagernet.fmt.internal.ChainBean;
|
||||
import io.nekohasekai.sagernet.fmt.naive.NaiveBean;
|
||||
import io.nekohasekai.sagernet.fmt.shadowsocks.ShadowsocksBean;
|
||||
import io.nekohasekai.sagernet.fmt.socks.SOCKSBean;
|
||||
import io.nekohasekai.sagernet.fmt.ssh.SSHBean;
|
||||
import io.nekohasekai.sagernet.fmt.trojan.TrojanBean;
|
||||
import io.nekohasekai.sagernet.fmt.trojan_go.TrojanGoBean;
|
||||
import io.nekohasekai.sagernet.fmt.tuic.TuicBean;
|
||||
import io.nekohasekai.sagernet.fmt.v2ray.VMessBean;
|
||||
import io.nekohasekai.sagernet.fmt.wireguard.WireGuardBean;
|
||||
import io.nekohasekai.sagernet.ktx.KryosKt;
|
||||
import io.nekohasekai.sagernet.ktx.Logs;
|
||||
import moe.matsuri.nb4a.proxy.neko.NekoBean;
|
||||
import moe.matsuri.nb4a.proxy.config.ConfigBean;
|
||||
import moe.matsuri.nb4a.utils.JavaUtil;
|
||||
|
||||
public class KryoConverters {
|
||||
|
||||
private static final byte[] NULL = new byte[0];
|
||||
|
||||
@TypeConverter
|
||||
public static byte[] serialize(Serializable bean) {
|
||||
if (bean == null) return NULL;
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
ByteBufferOutput buffer = KryosKt.byteBuffer(out);
|
||||
bean.serializeToBuffer(buffer);
|
||||
buffer.flush();
|
||||
buffer.close();
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
public static <T extends Serializable> T deserialize(T bean, byte[] bytes) {
|
||||
if (bytes == null) return bean;
|
||||
ByteArrayInputStream input = new ByteArrayInputStream(bytes);
|
||||
ByteBufferInput buffer = KryosKt.byteBuffer(input);
|
||||
try {
|
||||
bean.deserializeFromBuffer(buffer);
|
||||
} catch (KryoException e) {
|
||||
Logs.INSTANCE.w(e);
|
||||
}
|
||||
bean.initializeDefaultValues();
|
||||
return bean;
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
public static SOCKSBean socksDeserialize(byte[] bytes) {
|
||||
if (JavaUtil.isEmpty(bytes)) return null;
|
||||
return deserialize(new SOCKSBean(), bytes);
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
public static HttpBean httpDeserialize(byte[] bytes) {
|
||||
if (JavaUtil.isEmpty(bytes)) return null;
|
||||
return deserialize(new HttpBean(), bytes);
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
public static ShadowsocksBean shadowsocksDeserialize(byte[] bytes) {
|
||||
if (JavaUtil.isEmpty(bytes)) return null;
|
||||
return deserialize(new ShadowsocksBean(), bytes);
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
public static ConfigBean configDeserialize(byte[] bytes) {
|
||||
if (JavaUtil.isEmpty(bytes)) return null;
|
||||
return deserialize(new ConfigBean(), bytes);
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
public static VMessBean vmessDeserialize(byte[] bytes) {
|
||||
if (JavaUtil.isEmpty(bytes)) return null;
|
||||
return deserialize(new VMessBean(), bytes);
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
public static TrojanBean trojanDeserialize(byte[] bytes) {
|
||||
if (JavaUtil.isEmpty(bytes)) return null;
|
||||
return deserialize(new TrojanBean(), bytes);
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
public static TrojanGoBean trojanGoDeserialize(byte[] bytes) {
|
||||
if (JavaUtil.isEmpty(bytes)) return null;
|
||||
return deserialize(new TrojanGoBean(), bytes);
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
public static NaiveBean naiveDeserialize(byte[] bytes) {
|
||||
if (JavaUtil.isEmpty(bytes)) return null;
|
||||
return deserialize(new NaiveBean(), bytes);
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
public static HysteriaBean hysteriaDeserialize(byte[] bytes) {
|
||||
if (JavaUtil.isEmpty(bytes)) return null;
|
||||
return deserialize(new HysteriaBean(), bytes);
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
public static SSHBean sshDeserialize(byte[] bytes) {
|
||||
if (JavaUtil.isEmpty(bytes)) return null;
|
||||
return deserialize(new SSHBean(), bytes);
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
public static WireGuardBean wireguardDeserialize(byte[] bytes) {
|
||||
if (JavaUtil.isEmpty(bytes)) return null;
|
||||
return deserialize(new WireGuardBean(), bytes);
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
public static TuicBean tuicDeserialize(byte[] bytes) {
|
||||
if (JavaUtil.isEmpty(bytes)) return null;
|
||||
return deserialize(new TuicBean(), bytes);
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
public static ChainBean chainDeserialize(byte[] bytes) {
|
||||
if (JavaUtil.isEmpty(bytes)) return null;
|
||||
return deserialize(new ChainBean(), bytes);
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
public static NekoBean nekoDeserialize(byte[] bytes) {
|
||||
if (JavaUtil.isEmpty(bytes)) return null;
|
||||
return deserialize(new NekoBean(), bytes);
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
public static SubscriptionBean subscriptionDeserialize(byte[] bytes) {
|
||||
if (JavaUtil.isEmpty(bytes)) return null;
|
||||
return deserialize(new SubscriptionBean(), bytes);
|
||||
}
|
||||
|
||||
}
|
||||
58
app/src/main/java/io/nekohasekai/sagernet/fmt/PluginEntry.kt
Normal file
58
app/src/main/java/io/nekohasekai/sagernet/fmt/PluginEntry.kt
Normal file
@ -0,0 +1,58 @@
|
||||
package io.nekohasekai.sagernet.fmt
|
||||
|
||||
import io.nekohasekai.sagernet.R
|
||||
import io.nekohasekai.sagernet.SagerNet
|
||||
|
||||
enum class PluginEntry(
|
||||
val pluginId: String,
|
||||
val displayName: String,
|
||||
val packageName: String, // for play and f-droid page
|
||||
val downloadSource: DownloadSource = DownloadSource()
|
||||
) {
|
||||
TrojanGo(
|
||||
"trojan-go-plugin",
|
||||
SagerNet.application.getString(R.string.action_trojan_go),
|
||||
"io.nekohasekai.sagernet.plugin.trojan_go"
|
||||
),
|
||||
NaiveProxy(
|
||||
"naive-plugin",
|
||||
SagerNet.application.getString(R.string.action_naive),
|
||||
"io.nekohasekai.sagernet.plugin.naive"
|
||||
),
|
||||
Hysteria(
|
||||
"hysteria-plugin",
|
||||
SagerNet.application.getString(R.string.action_hysteria),
|
||||
"moe.matsuri.exe.hysteria", DownloadSource(
|
||||
playStore = false,
|
||||
fdroid = false,
|
||||
downloadLink = "https://github.com/MatsuriDayo/plugins/releases?q=Hysteria"
|
||||
)
|
||||
),
|
||||
TUIC(
|
||||
"tuic-plugin",
|
||||
SagerNet.application.getString(R.string.action_tuic),
|
||||
"io.nekohasekai.sagernet.plugin.tuic",
|
||||
DownloadSource(fdroid = false)
|
||||
),
|
||||
;
|
||||
|
||||
data class DownloadSource(
|
||||
val playStore: Boolean = true,
|
||||
val fdroid: Boolean = true,
|
||||
val downloadLink: String = "https://sagernet.org/download/"
|
||||
)
|
||||
|
||||
companion object {
|
||||
|
||||
fun find(name: String): PluginEntry? {
|
||||
for (pluginEntry in enumValues<PluginEntry>()) {
|
||||
if (name == pluginEntry.pluginId) {
|
||||
return pluginEntry
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
package io.nekohasekai.sagernet.fmt
|
||||
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import com.esotericsoftware.kryo.io.ByteBufferInput
|
||||
import com.esotericsoftware.kryo.io.ByteBufferOutput
|
||||
|
||||
abstract class Serializable : Parcelable {
|
||||
abstract fun initializeDefaultValues()
|
||||
abstract fun serializeToBuffer(output: ByteBufferOutput)
|
||||
abstract fun deserializeFromBuffer(input: ByteBufferInput)
|
||||
|
||||
override fun describeContents() = 0
|
||||
|
||||
override fun writeToParcel(dest: Parcel, flags: Int) {
|
||||
dest.writeByteArray(KryoConverters.serialize(this))
|
||||
}
|
||||
|
||||
abstract class CREATOR<T : Serializable> : Parcelable.Creator<T> {
|
||||
abstract fun newInstance(): T
|
||||
|
||||
override fun createFromParcel(source: Parcel): T {
|
||||
return KryoConverters.deserialize(newInstance(), source.createByteArray())
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
30
app/src/main/java/io/nekohasekai/sagernet/fmt/TypeMap.kt
Normal file
30
app/src/main/java/io/nekohasekai/sagernet/fmt/TypeMap.kt
Normal file
@ -0,0 +1,30 @@
|
||||
package io.nekohasekai.sagernet.fmt
|
||||
|
||||
import io.nekohasekai.sagernet.database.ProxyEntity
|
||||
|
||||
object TypeMap : HashMap<String, Int>() {
|
||||
init {
|
||||
this["socks"] = ProxyEntity.TYPE_SOCKS
|
||||
this["http"] = ProxyEntity.TYPE_HTTP
|
||||
this["ss"] = ProxyEntity.TYPE_SS
|
||||
this["vmess"] = ProxyEntity.TYPE_VMESS
|
||||
this["trojan"] = ProxyEntity.TYPE_TROJAN
|
||||
this["trojan-go"] = ProxyEntity.TYPE_TROJAN_GO
|
||||
this["naive"] = ProxyEntity.TYPE_NAIVE
|
||||
this["hysteria"] = ProxyEntity.TYPE_HYSTERIA
|
||||
this["ssh"] = ProxyEntity.TYPE_SSH
|
||||
this["wg"] = ProxyEntity.TYPE_WG
|
||||
this["tuic"] = ProxyEntity.TYPE_TUIC
|
||||
this["neko"] = ProxyEntity.TYPE_NEKO
|
||||
this["config"] = ProxyEntity.TYPE_CONFIG
|
||||
}
|
||||
|
||||
val reversed = HashMap<Int, String>()
|
||||
|
||||
init {
|
||||
TypeMap.forEach { (key, type) ->
|
||||
reversed[type] = key
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
package io.nekohasekai.sagernet.fmt
|
||||
|
||||
import io.nekohasekai.sagernet.database.ProxyEntity
|
||||
import io.nekohasekai.sagernet.database.ProxyGroup
|
||||
import moe.matsuri.nb4a.utils.Util
|
||||
|
||||
fun parseUniversal(link: String): AbstractBean {
|
||||
return if (link.contains("?")) {
|
||||
val type = link.substringAfter("sn://").substringBefore("?")
|
||||
ProxyEntity(type = TypeMap[type] ?: error("Type $type not found")).apply {
|
||||
putByteArray(Util.zlibDecompress(Util.b64Decode(link.substringAfter("?"))))
|
||||
}.requireBean()
|
||||
} else {
|
||||
val type = link.substringAfter("sn://").substringBefore(":")
|
||||
ProxyEntity(type = TypeMap[type] ?: error("Type $type not found")).apply {
|
||||
putByteArray(Util.b64Decode(link.substringAfter(":").substringAfter(":")))
|
||||
}.requireBean()
|
||||
}
|
||||
}
|
||||
|
||||
fun AbstractBean.toUniversalLink(): String {
|
||||
var link = "sn://"
|
||||
link += TypeMap.reversed[ProxyEntity().putBean(this).type]
|
||||
link += "?"
|
||||
link += Util.b64EncodeUrlSafe(Util.zlibCompress(KryoConverters.serialize(this), 9))
|
||||
return link
|
||||
}
|
||||
|
||||
|
||||
fun ProxyGroup.toUniversalLink(): String {
|
||||
var link = "sn://subscription?"
|
||||
export = true
|
||||
link += Util.b64EncodeUrlSafe(Util.zlibCompress(KryoConverters.serialize(this), 9))
|
||||
export = false
|
||||
return link
|
||||
}
|
||||
@ -0,0 +1,35 @@
|
||||
package io.nekohasekai.sagernet.fmt.gson;
|
||||
|
||||
import androidx.room.TypeConverter;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import kotlin.collections.CollectionsKt;
|
||||
import kotlin.collections.SetsKt;
|
||||
import moe.matsuri.nb4a.utils.JavaUtil;
|
||||
|
||||
public class GsonConverters {
|
||||
|
||||
@TypeConverter
|
||||
public static String toJson(Object value) {
|
||||
if (value instanceof Collection) {
|
||||
if (((Collection<?>) value).isEmpty()) return "";
|
||||
}
|
||||
return JavaUtil.gson.toJson(value);
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
public static List toList(String value) {
|
||||
if (JavaUtil.isNullOrBlank(value)) return CollectionsKt.listOf();
|
||||
return JavaUtil.gson.fromJson(value, List.class);
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
public static Set toSet(String value) {
|
||||
if (JavaUtil.isNullOrBlank(value)) return SetsKt.setOf();
|
||||
return JavaUtil.gson.fromJson(value, Set.class);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,59 @@
|
||||
package io.nekohasekai.sagernet.fmt.http;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.esotericsoftware.kryo.io.ByteBufferInput;
|
||||
import com.esotericsoftware.kryo.io.ByteBufferOutput;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import io.nekohasekai.sagernet.fmt.KryoConverters;
|
||||
import io.nekohasekai.sagernet.fmt.v2ray.StandardV2RayBean;
|
||||
|
||||
public class HttpBean extends StandardV2RayBean {
|
||||
|
||||
public String username;
|
||||
public String password;
|
||||
|
||||
@Override
|
||||
public void initializeDefaultValues() {
|
||||
super.initializeDefaultValues();
|
||||
if (username == null) username = "";
|
||||
if (password == null) password = "";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void serialize(ByteBufferOutput output) {
|
||||
output.writeInt(0);
|
||||
super.serialize(output);
|
||||
output.writeString(username);
|
||||
output.writeString(password);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deserialize(ByteBufferInput input) {
|
||||
int version = input.readInt();
|
||||
super.deserialize(input);
|
||||
username = input.readString();
|
||||
password = input.readString();
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Override
|
||||
public HttpBean clone() {
|
||||
return KryoConverters.deserialize(new HttpBean(), KryoConverters.serialize(this));
|
||||
}
|
||||
|
||||
public static final Creator<HttpBean> CREATOR = new CREATOR<HttpBean>() {
|
||||
@NonNull
|
||||
@Override
|
||||
public HttpBean newInstance() {
|
||||
return new HttpBean();
|
||||
}
|
||||
|
||||
@Override
|
||||
public HttpBean[] newArray(int size) {
|
||||
return new HttpBean[size];
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
package io.nekohasekai.sagernet.fmt.http
|
||||
|
||||
import io.nekohasekai.sagernet.fmt.v2ray.isTLS
|
||||
import io.nekohasekai.sagernet.fmt.v2ray.setTLS
|
||||
import io.nekohasekai.sagernet.ktx.urlSafe
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
|
||||
fun parseHttp(link: String): HttpBean {
|
||||
val httpUrl = link.toHttpUrlOrNull() ?: error("Invalid http(s) link: $link")
|
||||
|
||||
if (httpUrl.encodedPath != "/") error("Not http proxy")
|
||||
|
||||
return HttpBean().apply {
|
||||
serverAddress = httpUrl.host
|
||||
serverPort = httpUrl.port
|
||||
username = httpUrl.username
|
||||
password = httpUrl.password
|
||||
sni = httpUrl.queryParameter("sni")
|
||||
name = httpUrl.fragment
|
||||
setTLS(httpUrl.scheme == "https")
|
||||
}
|
||||
}
|
||||
|
||||
fun HttpBean.toUri(): String {
|
||||
val builder = HttpUrl.Builder().scheme(if (isTLS()) "https" else "http").host(serverAddress)
|
||||
|
||||
if (serverPort in 1..65535) {
|
||||
builder.port(serverPort)
|
||||
}
|
||||
|
||||
if (username.isNotBlank()) {
|
||||
builder.username(username)
|
||||
}
|
||||
if (password.isNotBlank()) {
|
||||
builder.password(password)
|
||||
}
|
||||
if (sni.isNotBlank()) {
|
||||
builder.addQueryParameter("sni", sni)
|
||||
}
|
||||
if (name.isNotBlank()) {
|
||||
builder.encodedFragment(name.urlSafe())
|
||||
}
|
||||
|
||||
return builder.toString()
|
||||
}
|
||||
@ -0,0 +1,155 @@
|
||||
package io.nekohasekai.sagernet.fmt.hysteria;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.esotericsoftware.kryo.io.ByteBufferInput;
|
||||
import com.esotericsoftware.kryo.io.ByteBufferOutput;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import io.nekohasekai.sagernet.fmt.AbstractBean;
|
||||
import io.nekohasekai.sagernet.fmt.KryoConverters;
|
||||
|
||||
public class HysteriaBean extends AbstractBean {
|
||||
|
||||
public static final int TYPE_NONE = 0;
|
||||
public static final int TYPE_STRING = 1;
|
||||
public static final int TYPE_BASE64 = 2;
|
||||
|
||||
public Integer authPayloadType;
|
||||
public String authPayload;
|
||||
|
||||
public static final int PROTOCOL_UDP = 0;
|
||||
public static final int PROTOCOL_FAKETCP = 1;
|
||||
public static final int PROTOCOL_WECHAT_VIDEO = 2;
|
||||
|
||||
public Integer protocol;
|
||||
|
||||
public String obfuscation;
|
||||
public String sni;
|
||||
public String alpn;
|
||||
public String caText;
|
||||
|
||||
public Integer uploadMbps;
|
||||
public Integer downloadMbps;
|
||||
public Boolean allowInsecure;
|
||||
public Integer streamReceiveWindow;
|
||||
public Integer connectionReceiveWindow;
|
||||
public Boolean disableMtuDiscovery;
|
||||
public Integer hopInterval;
|
||||
|
||||
@Override
|
||||
public boolean canMapping() {
|
||||
return protocol != PROTOCOL_FAKETCP;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void initializeDefaultValues() {
|
||||
super.initializeDefaultValues();
|
||||
if (authPayloadType == null) authPayloadType = TYPE_NONE;
|
||||
if (authPayload == null) authPayload = "";
|
||||
if (protocol == null) protocol = PROTOCOL_UDP;
|
||||
if (obfuscation == null) obfuscation = "";
|
||||
if (sni == null) sni = "";
|
||||
if (alpn == null) alpn = "";
|
||||
if (caText == null) caText = "";
|
||||
|
||||
if (uploadMbps == null) uploadMbps = 10;
|
||||
if (downloadMbps == null) downloadMbps = 50;
|
||||
if (allowInsecure == null) allowInsecure = false;
|
||||
|
||||
if (streamReceiveWindow == null) streamReceiveWindow = 0;
|
||||
if (connectionReceiveWindow == null) connectionReceiveWindow = 0;
|
||||
if (disableMtuDiscovery == null) disableMtuDiscovery = false;
|
||||
if (hopInterval == null) hopInterval = 10;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void serialize(ByteBufferOutput output) {
|
||||
output.writeInt(5);
|
||||
super.serialize(output);
|
||||
output.writeInt(authPayloadType);
|
||||
output.writeString(authPayload);
|
||||
output.writeInt(protocol);
|
||||
output.writeString(obfuscation);
|
||||
output.writeString(sni);
|
||||
output.writeString(alpn);
|
||||
|
||||
output.writeInt(uploadMbps);
|
||||
output.writeInt(downloadMbps);
|
||||
output.writeBoolean(allowInsecure);
|
||||
|
||||
output.writeString(caText);
|
||||
output.writeInt(streamReceiveWindow);
|
||||
output.writeInt(connectionReceiveWindow);
|
||||
output.writeBoolean(disableMtuDiscovery);
|
||||
output.writeInt(hopInterval);
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deserialize(ByteBufferInput input) {
|
||||
int version = input.readInt();
|
||||
super.deserialize(input);
|
||||
authPayloadType = input.readInt();
|
||||
authPayload = input.readString();
|
||||
if (version >= 3) {
|
||||
protocol = input.readInt();
|
||||
}
|
||||
obfuscation = input.readString();
|
||||
sni = input.readString();
|
||||
if (version >= 2) {
|
||||
alpn = input.readString();
|
||||
}
|
||||
uploadMbps = input.readInt();
|
||||
downloadMbps = input.readInt();
|
||||
allowInsecure = input.readBoolean();
|
||||
if (version >= 1) {
|
||||
caText = input.readString();
|
||||
streamReceiveWindow = input.readInt();
|
||||
connectionReceiveWindow = input.readInt();
|
||||
if (version != 4) disableMtuDiscovery = input.readBoolean(); // note: skip 4
|
||||
}
|
||||
if (version >= 5) {
|
||||
hopInterval = input.readInt();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void applyFeatureSettings(AbstractBean other) {
|
||||
if (!(other instanceof HysteriaBean)) return;
|
||||
HysteriaBean bean = ((HysteriaBean) other);
|
||||
bean.uploadMbps = uploadMbps;
|
||||
bean.downloadMbps = downloadMbps;
|
||||
bean.allowInsecure = allowInsecure;
|
||||
bean.disableMtuDiscovery = disableMtuDiscovery;
|
||||
bean.hopInterval = hopInterval;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String displayAddress() {
|
||||
if (HysteriaFmtKt.isMultiPort(this)) {
|
||||
return serverAddress;
|
||||
}
|
||||
return super.displayAddress();
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Override
|
||||
public HysteriaBean clone() {
|
||||
return KryoConverters.deserialize(new HysteriaBean(), KryoConverters.serialize(this));
|
||||
}
|
||||
|
||||
public static final Creator<HysteriaBean> CREATOR = new CREATOR<HysteriaBean>() {
|
||||
@NonNull
|
||||
@Override
|
||||
public HysteriaBean newInstance() {
|
||||
return new HysteriaBean();
|
||||
}
|
||||
|
||||
@Override
|
||||
public HysteriaBean[] newArray(int size) {
|
||||
return new HysteriaBean[size];
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,237 @@
|
||||
package io.nekohasekai.sagernet.fmt.hysteria
|
||||
|
||||
import io.nekohasekai.sagernet.database.DataStore
|
||||
import io.nekohasekai.sagernet.fmt.LOCALHOST
|
||||
import io.nekohasekai.sagernet.ktx.*
|
||||
import moe.matsuri.nb4a.SingBoxOptions
|
||||
import moe.matsuri.nb4a.utils.Util
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
|
||||
// hysteria://host:port?auth=123456&peer=sni.domain&insecure=1|0&upmbps=100&downmbps=100&alpn=hysteria&obfs=xplus&obfsParam=123456#remarks
|
||||
|
||||
fun parseHysteria(url: String): HysteriaBean {
|
||||
val link = url.replace("hysteria://", "https://").toHttpUrlOrNull() ?: error(
|
||||
"invalid hysteria link $url"
|
||||
)
|
||||
return HysteriaBean().apply {
|
||||
serverAddress = link.host
|
||||
serverPort = link.port
|
||||
name = link.fragment
|
||||
|
||||
link.queryParameter("mport")?.also {
|
||||
serverAddress = serverAddress.wrapIPV6Host() + ":" + it
|
||||
}
|
||||
link.queryParameter("peer")?.also {
|
||||
sni = it
|
||||
}
|
||||
link.queryParameter("auth")?.takeIf { it.isNotBlank() }?.also {
|
||||
authPayloadType = HysteriaBean.TYPE_STRING
|
||||
authPayload = it
|
||||
}
|
||||
link.queryParameter("insecure")?.also {
|
||||
allowInsecure = it == "1"
|
||||
}
|
||||
link.queryParameter("upmbps")?.also {
|
||||
uploadMbps = it.toIntOrNull() ?: uploadMbps
|
||||
}
|
||||
link.queryParameter("downmbps")?.also {
|
||||
downloadMbps = it.toIntOrNull() ?: downloadMbps
|
||||
}
|
||||
link.queryParameter("alpn")?.also {
|
||||
alpn = it
|
||||
}
|
||||
link.queryParameter("obfsParam")?.also {
|
||||
obfuscation = it
|
||||
}
|
||||
link.queryParameter("protocol")?.also {
|
||||
when (it) {
|
||||
"faketcp" -> {
|
||||
protocol = HysteriaBean.PROTOCOL_FAKETCP
|
||||
}
|
||||
"wechat-video" -> {
|
||||
protocol = HysteriaBean.PROTOCOL_WECHAT_VIDEO
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun HysteriaBean.toUri(): String {
|
||||
val builder = linkBuilder().host(serverAddress.substringBeforeLast(":")).port(serverPort)
|
||||
if (isMultiPort()) {
|
||||
builder.addQueryParameter("mport", serverAddress.substringAfterLast(":"))
|
||||
}
|
||||
if (allowInsecure) {
|
||||
builder.addQueryParameter("insecure", "1")
|
||||
}
|
||||
if (sni.isNotBlank()) {
|
||||
builder.addQueryParameter("peer", sni)
|
||||
}
|
||||
if (authPayload.isNotBlank()) {
|
||||
builder.addQueryParameter("auth", authPayload)
|
||||
}
|
||||
builder.addQueryParameter("upmbps", "$uploadMbps")
|
||||
builder.addQueryParameter("downmbps", "$downloadMbps")
|
||||
if (alpn.isNotBlank()) {
|
||||
builder.addQueryParameter("alpn", alpn)
|
||||
}
|
||||
if (obfuscation.isNotBlank()) {
|
||||
builder.addQueryParameter("obfs", "xplus")
|
||||
builder.addQueryParameter("obfsParam", obfuscation)
|
||||
}
|
||||
when (protocol) {
|
||||
HysteriaBean.PROTOCOL_FAKETCP -> {
|
||||
builder.addQueryParameter("protocol", "faketcp")
|
||||
}
|
||||
HysteriaBean.PROTOCOL_WECHAT_VIDEO -> {
|
||||
builder.addQueryParameter("protocol", "wechat-video")
|
||||
}
|
||||
}
|
||||
if (protocol == HysteriaBean.PROTOCOL_FAKETCP) {
|
||||
builder.addQueryParameter("protocol", "faketcp")
|
||||
}
|
||||
if (name.isNotBlank()) {
|
||||
builder.encodedFragment(name.urlSafe())
|
||||
}
|
||||
return builder.toLink("hysteria")
|
||||
}
|
||||
|
||||
fun JSONObject.parseHysteria(): HysteriaBean {
|
||||
return HysteriaBean().apply {
|
||||
serverAddress = optString("server")
|
||||
if (!isMultiPort()) {
|
||||
serverAddress = optString("server").substringBeforeLast(":")
|
||||
serverPort = optString("server").substringAfterLast(":").toIntOrNull() ?: 443
|
||||
}
|
||||
uploadMbps = getIntNya("up_mbps")
|
||||
downloadMbps = getIntNya("down_mbps")
|
||||
obfuscation = getStr("obfs")
|
||||
getStr("auth")?.also {
|
||||
authPayloadType = HysteriaBean.TYPE_BASE64
|
||||
authPayload = it
|
||||
}
|
||||
getStr("auth_str")?.also {
|
||||
authPayloadType = HysteriaBean.TYPE_STRING
|
||||
authPayload = it
|
||||
}
|
||||
getStr("protocol")?.also {
|
||||
when (it) {
|
||||
"faketcp" -> {
|
||||
protocol = HysteriaBean.PROTOCOL_FAKETCP
|
||||
}
|
||||
"wechat-video" -> {
|
||||
protocol = HysteriaBean.PROTOCOL_WECHAT_VIDEO
|
||||
}
|
||||
}
|
||||
}
|
||||
sni = getStr("server_name")
|
||||
alpn = getStr("alpn")
|
||||
allowInsecure = getBool("insecure")
|
||||
|
||||
streamReceiveWindow = getIntNya("recv_window_conn")
|
||||
connectionReceiveWindow = getIntNya("recv_window")
|
||||
disableMtuDiscovery = getBool("disable_mtu_discovery")
|
||||
}
|
||||
}
|
||||
|
||||
fun HysteriaBean.buildHysteriaConfig(port: Int, cacheFile: (() -> File)?): String {
|
||||
return JSONObject().apply {
|
||||
put("server", if (isMultiPort()) serverAddress else wrapUri())
|
||||
when (protocol) {
|
||||
HysteriaBean.PROTOCOL_FAKETCP -> {
|
||||
put("protocol", "faketcp")
|
||||
}
|
||||
HysteriaBean.PROTOCOL_WECHAT_VIDEO -> {
|
||||
put("protocol", "wechat-video")
|
||||
}
|
||||
}
|
||||
put("up_mbps", uploadMbps)
|
||||
put("down_mbps", downloadMbps)
|
||||
put(
|
||||
"socks5", JSONObject(
|
||||
mapOf(
|
||||
"listen" to "$LOCALHOST:$port",
|
||||
)
|
||||
)
|
||||
)
|
||||
put("retry", 5)
|
||||
put("obfs", obfuscation)
|
||||
when (authPayloadType) {
|
||||
HysteriaBean.TYPE_BASE64 -> put("auth", authPayload)
|
||||
HysteriaBean.TYPE_STRING -> put("auth_str", authPayload)
|
||||
}
|
||||
if (sni.isBlank() && finalAddress == LOCALHOST && !serverAddress.isIpAddress()) {
|
||||
sni = serverAddress
|
||||
}
|
||||
if (sni.isNotBlank()) {
|
||||
put("server_name", sni)
|
||||
}
|
||||
if (alpn.isNotBlank()) put("alpn", alpn)
|
||||
if (caText.isNotBlank() && cacheFile != null) {
|
||||
val caFile = cacheFile()
|
||||
caFile.writeText(caText)
|
||||
put("ca", caFile.absolutePath)
|
||||
}
|
||||
|
||||
if (allowInsecure) put("insecure", true)
|
||||
if (streamReceiveWindow > 0) put("recv_window_conn", streamReceiveWindow)
|
||||
if (connectionReceiveWindow > 0) put("recv_window", connectionReceiveWindow)
|
||||
if (disableMtuDiscovery) put("disable_mtu_discovery", true)
|
||||
|
||||
// hy 1.2.0 (不兼容)
|
||||
put("resolver", "udp://127.0.0.1:" + DataStore.localDNSPort)
|
||||
|
||||
put("hop_interval", hopInterval)
|
||||
}.toStringPretty()
|
||||
}
|
||||
|
||||
fun HysteriaBean.isMultiPort(): Boolean {
|
||||
if (!serverAddress.contains(":")) return false
|
||||
val p = serverAddress.substringAfterLast(":")
|
||||
if (p.contains("-") || p.contains(",")) return true
|
||||
return false
|
||||
}
|
||||
|
||||
fun HysteriaBean.canUseSingBox(): Boolean {
|
||||
if (isMultiPort() || protocol != HysteriaBean.PROTOCOL_UDP) return false
|
||||
return true
|
||||
}
|
||||
|
||||
fun buildSingBoxOutboundHysteriaBean(bean: HysteriaBean): SingBoxOptions.Outbound_HysteriaOptions {
|
||||
// No multi-port
|
||||
return SingBoxOptions.Outbound_HysteriaOptions().apply {
|
||||
type = "hysteria"
|
||||
server = bean.serverAddress
|
||||
server_port = bean.serverPort
|
||||
up_mbps = bean.uploadMbps
|
||||
down_mbps = bean.downloadMbps
|
||||
obfs = bean.obfuscation
|
||||
disable_mtu_discovery = bean.disableMtuDiscovery
|
||||
when (bean.authPayloadType) {
|
||||
HysteriaBean.TYPE_BASE64 -> auth = Util.b64Decode(bean.authPayload).toList()
|
||||
HysteriaBean.TYPE_STRING -> auth_str = bean.authPayload
|
||||
}
|
||||
if (bean.streamReceiveWindow > 0) {
|
||||
recv_window_conn = bean.streamReceiveWindow.toLong()
|
||||
}
|
||||
if (bean.connectionReceiveWindow > 0) {
|
||||
recv_window_conn = bean.connectionReceiveWindow.toLong()
|
||||
}
|
||||
tls = SingBoxOptions.OutboundTLSOptions().apply {
|
||||
if (bean.sni.isNotBlank()) {
|
||||
server_name = bean.sni
|
||||
}
|
||||
if (bean.alpn.isNotBlank()) {
|
||||
alpn = bean.alpn.split("\n")
|
||||
}
|
||||
if (bean.caText.isNotBlank()) {
|
||||
certificate = bean.caText
|
||||
}
|
||||
insecure = bean.allowInsecure
|
||||
enabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,80 @@
|
||||
package io.nekohasekai.sagernet.fmt.internal;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.esotericsoftware.kryo.io.ByteBufferInput;
|
||||
import com.esotericsoftware.kryo.io.ByteBufferOutput;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import io.nekohasekai.sagernet.fmt.KryoConverters;
|
||||
import moe.matsuri.nb4a.utils.JavaUtil;
|
||||
|
||||
public class ChainBean extends InternalBean {
|
||||
|
||||
public List<Long> proxies;
|
||||
|
||||
@Override
|
||||
public String displayName() {
|
||||
if (JavaUtil.isNotBlank(name)) {
|
||||
return name;
|
||||
} else {
|
||||
return "Chain " + Math.abs(hashCode());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void initializeDefaultValues() {
|
||||
super.initializeDefaultValues();
|
||||
if (name == null) name = "";
|
||||
|
||||
if (proxies == null) {
|
||||
proxies = new ArrayList<>();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void serialize(ByteBufferOutput output) {
|
||||
output.writeInt(1);
|
||||
output.writeInt(proxies.size());
|
||||
for (Long proxy : proxies) {
|
||||
output.writeLong(proxy);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deserialize(ByteBufferInput input) {
|
||||
int version = input.readInt();
|
||||
if (version < 1) {
|
||||
input.readString();
|
||||
input.readInt();
|
||||
}
|
||||
int length = input.readInt();
|
||||
proxies = new ArrayList<>();
|
||||
for (int i = 0; i < length; i++) {
|
||||
proxies.add(input.readLong());
|
||||
}
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Override
|
||||
public ChainBean clone() {
|
||||
return KryoConverters.deserialize(new ChainBean(), KryoConverters.serialize(this));
|
||||
}
|
||||
|
||||
public static final Creator<ChainBean> CREATOR = new CREATOR<ChainBean>() {
|
||||
@NonNull
|
||||
@Override
|
||||
public ChainBean newInstance() {
|
||||
return new ChainBean();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChainBean[] newArray(int size) {
|
||||
return new ChainBean[size];
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
package io.nekohasekai.sagernet.fmt.internal;
|
||||
|
||||
import io.nekohasekai.sagernet.fmt.AbstractBean;
|
||||
|
||||
public abstract class InternalBean extends AbstractBean {
|
||||
|
||||
@Override
|
||||
public String displayAddress() {
|
||||
return "";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean canICMPing() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean canTCPing() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean canMapping() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,88 @@
|
||||
package io.nekohasekai.sagernet.fmt.naive;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.esotericsoftware.kryo.io.ByteBufferInput;
|
||||
import com.esotericsoftware.kryo.io.ByteBufferOutput;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import io.nekohasekai.sagernet.fmt.AbstractBean;
|
||||
import io.nekohasekai.sagernet.fmt.KryoConverters;
|
||||
|
||||
public class NaiveBean extends AbstractBean {
|
||||
|
||||
/**
|
||||
* Available proto: https, quic.
|
||||
*/
|
||||
public String proto;
|
||||
public String username;
|
||||
public String password;
|
||||
public String extraHeaders;
|
||||
public String sni;
|
||||
public String certificates;
|
||||
public Integer insecureConcurrency;
|
||||
|
||||
@Override
|
||||
public void initializeDefaultValues() {
|
||||
if (serverPort == null) serverPort = 443;
|
||||
super.initializeDefaultValues();
|
||||
if (proto == null) proto = "https";
|
||||
if (username == null) username = "";
|
||||
if (password == null) password = "";
|
||||
if (extraHeaders == null) extraHeaders = "";
|
||||
if (certificates == null) certificates = "";
|
||||
if (sni == null) sni = "";
|
||||
if (insecureConcurrency == null) insecureConcurrency = 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void serialize(ByteBufferOutput output) {
|
||||
output.writeInt(2);
|
||||
super.serialize(output);
|
||||
output.writeString(proto);
|
||||
output.writeString(username);
|
||||
output.writeString(password);
|
||||
// note: sequence is different from SagerNet,,,
|
||||
output.writeString(extraHeaders);
|
||||
output.writeString(certificates);
|
||||
output.writeString(sni);
|
||||
output.writeInt(insecureConcurrency);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deserialize(ByteBufferInput input) {
|
||||
int version = input.readInt();
|
||||
super.deserialize(input);
|
||||
proto = input.readString();
|
||||
username = input.readString();
|
||||
password = input.readString();
|
||||
extraHeaders = input.readString();
|
||||
if (version >= 2) {
|
||||
certificates = input.readString();
|
||||
sni = input.readString();
|
||||
}
|
||||
if (version >= 1) {
|
||||
insecureConcurrency = input.readInt();
|
||||
}
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Override
|
||||
public NaiveBean clone() {
|
||||
return KryoConverters.deserialize(new NaiveBean(), KryoConverters.serialize(this));
|
||||
}
|
||||
|
||||
public static final Creator<NaiveBean> CREATOR = new CREATOR<NaiveBean>() {
|
||||
@NonNull
|
||||
@Override
|
||||
public NaiveBean newInstance() {
|
||||
return new NaiveBean();
|
||||
}
|
||||
|
||||
@Override
|
||||
public NaiveBean[] newArray(int size) {
|
||||
return new NaiveBean[size];
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,90 @@
|
||||
package io.nekohasekai.sagernet.fmt.naive
|
||||
|
||||
import io.nekohasekai.sagernet.database.DataStore
|
||||
import io.nekohasekai.sagernet.fmt.LOCALHOST
|
||||
import io.nekohasekai.sagernet.ktx.*
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import org.json.JSONObject
|
||||
|
||||
fun parseNaive(link: String): NaiveBean {
|
||||
val proto = link.substringAfter("+").substringBefore(":")
|
||||
val url = ("https://" + link.substringAfter("://")).toHttpUrlOrNull()
|
||||
?: error("Invalid naive link: $link")
|
||||
return NaiveBean().also {
|
||||
it.proto = proto
|
||||
}.apply {
|
||||
serverAddress = url.host
|
||||
serverPort = url.port
|
||||
username = url.username
|
||||
password = url.password
|
||||
sni = url.queryParameter("sni")
|
||||
certificates = url.queryParameter("cert")
|
||||
extraHeaders = url.queryParameter("extra-headers")?.unUrlSafe()?.replace("\r\n", "\n")
|
||||
insecureConcurrency = url.queryParameter("insecure-concurrency")?.toIntOrNull()
|
||||
name = url.fragment
|
||||
initializeDefaultValues()
|
||||
}
|
||||
}
|
||||
|
||||
fun NaiveBean.toUri(proxyOnly: Boolean = false): String {
|
||||
val builder = linkBuilder().host(finalAddress).port(finalPort)
|
||||
if (username.isNotBlank()) {
|
||||
builder.username(username)
|
||||
if (password.isNotBlank()) {
|
||||
builder.password(password)
|
||||
}
|
||||
}
|
||||
if (!proxyOnly) {
|
||||
if (sni.isNotBlank()) {
|
||||
builder.addQueryParameter("sni", sni)
|
||||
}
|
||||
if (certificates.isNotBlank()) {
|
||||
builder.addQueryParameter("cert", certificates)
|
||||
}
|
||||
if (extraHeaders.isNotBlank()) {
|
||||
builder.addQueryParameter("extra-headers", extraHeaders)
|
||||
}
|
||||
if (name.isNotBlank()) {
|
||||
builder.encodedFragment(name.urlSafe())
|
||||
}
|
||||
if (insecureConcurrency > 0) {
|
||||
builder.addQueryParameter("insecure-concurrency", "$insecureConcurrency")
|
||||
}
|
||||
}
|
||||
return builder.toLink(if (proxyOnly) proto else "naive+$proto", false)
|
||||
}
|
||||
|
||||
fun NaiveBean.buildNaiveConfig(port: Int): String {
|
||||
return JSONObject().apply {
|
||||
// process ipv6
|
||||
finalAddress = finalAddress.wrapIPV6Host()
|
||||
serverAddress = serverAddress.wrapIPV6Host()
|
||||
|
||||
// process sni
|
||||
if (sni.isNotBlank()) {
|
||||
put("host-resolver-rules", "MAP $sni $finalAddress")
|
||||
finalAddress = sni
|
||||
} else {
|
||||
if (serverAddress.isIpAddress()) {
|
||||
// for naive, using IP as SNI name hardly happens
|
||||
// and host-resolver-rules cannot resolve the SNI problem
|
||||
// so do nothing
|
||||
} else {
|
||||
put("host-resolver-rules", "MAP $serverAddress $finalAddress")
|
||||
finalAddress = serverAddress
|
||||
}
|
||||
}
|
||||
|
||||
put("listen", "socks://$LOCALHOST:$port")
|
||||
put("proxy", toUri(true))
|
||||
if (extraHeaders.isNotBlank()) {
|
||||
put("extra-headers", extraHeaders.split("\n").joinToString("\r\n"))
|
||||
}
|
||||
if (DataStore.enableLog) {
|
||||
put("log", "")
|
||||
}
|
||||
if (insecureConcurrency > 0) {
|
||||
put("insecure-concurrency", insecureConcurrency)
|
||||
}
|
||||
}.toStringPretty()
|
||||
}
|
||||
@ -0,0 +1,71 @@
|
||||
package io.nekohasekai.sagernet.fmt.shadowsocks;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.esotericsoftware.kryo.io.ByteBufferInput;
|
||||
import com.esotericsoftware.kryo.io.ByteBufferOutput;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import io.nekohasekai.sagernet.fmt.AbstractBean;
|
||||
import io.nekohasekai.sagernet.fmt.KryoConverters;
|
||||
import moe.matsuri.nb4a.utils.JavaUtil;
|
||||
|
||||
public class ShadowsocksBean extends AbstractBean {
|
||||
|
||||
public String method;
|
||||
public String password;
|
||||
public String plugin;
|
||||
|
||||
public Boolean sUoT;
|
||||
|
||||
@Override
|
||||
public void initializeDefaultValues() {
|
||||
super.initializeDefaultValues();
|
||||
|
||||
if (JavaUtil.isNullOrBlank(method)) method = "aes-256-gcm";
|
||||
if (method == null) method = "";
|
||||
if (password == null) password = "";
|
||||
if (plugin == null) plugin = "";
|
||||
if (sUoT == null) sUoT = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void serialize(ByteBufferOutput output) {
|
||||
output.writeInt(2);
|
||||
super.serialize(output);
|
||||
output.writeString(method);
|
||||
output.writeString(password);
|
||||
output.writeString(plugin);
|
||||
output.writeBoolean(sUoT);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deserialize(ByteBufferInput input) {
|
||||
int version = input.readInt();
|
||||
super.deserialize(input);
|
||||
method = input.readString();
|
||||
password = input.readString();
|
||||
plugin = input.readString();
|
||||
sUoT = input.readBoolean();
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Override
|
||||
public ShadowsocksBean clone() {
|
||||
return KryoConverters.deserialize(new ShadowsocksBean(), KryoConverters.serialize(this));
|
||||
}
|
||||
|
||||
public static final Creator<ShadowsocksBean> CREATOR = new CREATOR<ShadowsocksBean>() {
|
||||
@NonNull
|
||||
@Override
|
||||
public ShadowsocksBean newInstance() {
|
||||
return new ShadowsocksBean();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ShadowsocksBean[] newArray(int size) {
|
||||
return new ShadowsocksBean[size];
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,115 @@
|
||||
package io.nekohasekai.sagernet.fmt.shadowsocks
|
||||
|
||||
import moe.matsuri.nb4a.SingBoxOptions
|
||||
import io.nekohasekai.sagernet.ktx.*
|
||||
import moe.matsuri.nb4a.utils.Util
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import org.json.JSONObject
|
||||
|
||||
fun parseShadowsocks(url: String): ShadowsocksBean {
|
||||
|
||||
if (url.substringBefore("#").contains("@")) {
|
||||
var link = url.replace("ss://", "https://").toHttpUrlOrNull() ?: error(
|
||||
"invalid ss-android link $url"
|
||||
)
|
||||
|
||||
if (link.username.isBlank()) { // fix justmysocks's shit link
|
||||
link = (("https://" + url.substringAfter("ss://")
|
||||
.substringBefore("#")
|
||||
.decodeBase64UrlSafe()).toHttpUrlOrNull()
|
||||
?: error("invalid jms link $url")
|
||||
).newBuilder().fragment(url.substringAfter("#")).build()
|
||||
}
|
||||
|
||||
// ss-android style
|
||||
|
||||
if (link.password.isNotBlank()) {
|
||||
return ShadowsocksBean().apply {
|
||||
serverAddress = link.host
|
||||
serverPort = link.port
|
||||
method = link.username
|
||||
password = link.password
|
||||
plugin = link.queryParameter("plugin") ?: ""
|
||||
name = link.fragment
|
||||
}
|
||||
}
|
||||
|
||||
val methodAndPswd = link.username.decodeBase64UrlSafe()
|
||||
|
||||
return ShadowsocksBean().apply {
|
||||
serverAddress = link.host
|
||||
serverPort = link.port
|
||||
method = methodAndPswd.substringBefore(":")
|
||||
password = methodAndPswd.substringAfter(":")
|
||||
plugin = link.queryParameter("plugin") ?: ""
|
||||
name = link.fragment
|
||||
}
|
||||
} else {
|
||||
// v2rayN style
|
||||
var v2Url = url
|
||||
|
||||
if (v2Url.contains("#")) v2Url = v2Url.substringBefore("#")
|
||||
|
||||
val link = ("https://" + v2Url.substringAfter("ss://")
|
||||
.decodeBase64UrlSafe()).toHttpUrlOrNull() ?: error("invalid v2rayN link $url")
|
||||
|
||||
return ShadowsocksBean().apply {
|
||||
serverAddress = link.host
|
||||
serverPort = link.port
|
||||
method = link.username
|
||||
password = link.password
|
||||
plugin = ""
|
||||
val remarks = url.substringAfter("#").unUrlSafe()
|
||||
if (remarks.isNotBlank()) name = remarks
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun ShadowsocksBean.toUri(): String {
|
||||
|
||||
val builder = linkBuilder().username(Util.b64EncodeUrlSafe("$method:$password"))
|
||||
.host(serverAddress)
|
||||
.port(serverPort)
|
||||
|
||||
if (plugin.isNotBlank()) {
|
||||
builder.addQueryParameter("plugin", plugin)
|
||||
}
|
||||
|
||||
if (name.isNotBlank()) {
|
||||
builder.encodedFragment(name.urlSafe())
|
||||
}
|
||||
|
||||
return builder.toLink("ss").replace("$serverPort/", "$serverPort")
|
||||
|
||||
}
|
||||
|
||||
fun JSONObject.parseShadowsocks(): ShadowsocksBean {
|
||||
return ShadowsocksBean().apply {
|
||||
serverAddress = getStr("server")
|
||||
serverPort = getIntNya("server_port")
|
||||
password = getStr("password")
|
||||
method = getStr("method")
|
||||
name = optString("remarks", "")
|
||||
|
||||
val pId = getStr("plugin")
|
||||
if (!pId.isNullOrBlank()) {
|
||||
plugin = pId + ";" + optString("plugin_opts", "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun buildSingBoxOutboundShadowsocksBean(bean: ShadowsocksBean): SingBoxOptions.Outbound_ShadowsocksOptions {
|
||||
return SingBoxOptions.Outbound_ShadowsocksOptions().apply {
|
||||
type = "shadowsocks"
|
||||
server = bean.serverAddress
|
||||
server_port = bean.serverPort
|
||||
password = bean.password
|
||||
method = bean.method
|
||||
udp_over_tcp = bean.sUoT
|
||||
if (bean.plugin.isNotBlank()) {
|
||||
plugin = bean.plugin.substringBefore(";")
|
||||
plugin_opts = bean.plugin.substringAfter(";")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,110 @@
|
||||
package io.nekohasekai.sagernet.fmt.socks;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.esotericsoftware.kryo.io.ByteBufferInput;
|
||||
import com.esotericsoftware.kryo.io.ByteBufferOutput;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import io.nekohasekai.sagernet.fmt.AbstractBean;
|
||||
import io.nekohasekai.sagernet.fmt.KryoConverters;
|
||||
|
||||
public class SOCKSBean extends AbstractBean {
|
||||
|
||||
public Integer protocol;
|
||||
|
||||
public int protocolVersion() {
|
||||
switch (protocol) {
|
||||
case 0:
|
||||
case 1:
|
||||
return 4;
|
||||
default:
|
||||
return 5;
|
||||
}
|
||||
}
|
||||
|
||||
public String protocolName() {
|
||||
switch (protocol) {
|
||||
case 0:
|
||||
return "SOCKS4";
|
||||
case 1:
|
||||
return "SOCKS4A";
|
||||
default:
|
||||
return "SOCKS5";
|
||||
}
|
||||
}
|
||||
|
||||
public String protocolVersionName() {
|
||||
switch (protocol) {
|
||||
case 0:
|
||||
return "4";
|
||||
case 1:
|
||||
return "4a";
|
||||
default:
|
||||
return "5";
|
||||
}
|
||||
}
|
||||
|
||||
public String username;
|
||||
public String password;
|
||||
|
||||
public static final int PROTOCOL_SOCKS4 = 0;
|
||||
public static final int PROTOCOL_SOCKS4A = 1;
|
||||
public static final int PROTOCOL_SOCKS5 = 2;
|
||||
|
||||
@Override
|
||||
public String network() {
|
||||
if (protocol < PROTOCOL_SOCKS5) return "tcp";
|
||||
return super.network();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void initializeDefaultValues() {
|
||||
super.initializeDefaultValues();
|
||||
|
||||
if (protocol == null) protocol = PROTOCOL_SOCKS5;
|
||||
if (username == null) username = "";
|
||||
if (password == null) password = "";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void serialize(ByteBufferOutput output) {
|
||||
output.writeInt(1);
|
||||
super.serialize(output);
|
||||
output.writeInt(protocol);
|
||||
output.writeString(username);
|
||||
output.writeString(password);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deserialize(ByteBufferInput input) {
|
||||
int version = input.readInt();
|
||||
super.deserialize(input);
|
||||
if (version >= 1) {
|
||||
protocol = input.readInt();
|
||||
}
|
||||
username = input.readString();
|
||||
password = input.readString();
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Override
|
||||
public SOCKSBean clone() {
|
||||
return KryoConverters.deserialize(new SOCKSBean(), KryoConverters.serialize(this));
|
||||
}
|
||||
|
||||
public static final Creator<SOCKSBean> CREATOR = new CREATOR<SOCKSBean>() {
|
||||
@NonNull
|
||||
@Override
|
||||
public SOCKSBean newInstance() {
|
||||
return new SOCKSBean();
|
||||
}
|
||||
|
||||
@Override
|
||||
public SOCKSBean[] newArray(int size) {
|
||||
return new SOCKSBean[size];
|
||||
}
|
||||
};
|
||||
|
||||
}
|
||||
@ -0,0 +1,81 @@
|
||||
package io.nekohasekai.sagernet.fmt.socks
|
||||
|
||||
import moe.matsuri.nb4a.SingBoxOptions
|
||||
import io.nekohasekai.sagernet.ktx.*
|
||||
import moe.matsuri.nb4a.utils.NGUtil
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
|
||||
fun parseSOCKS(link: String): SOCKSBean {
|
||||
if (!link.substringAfter("://").contains(":")) {
|
||||
// v2rayN shit format
|
||||
var url = link.substringAfter("://")
|
||||
if (url.contains("#")) {
|
||||
url = url.substringBeforeLast("#")
|
||||
}
|
||||
url = url.decodeBase64UrlSafe()
|
||||
val httpUrl = "http://$url".toHttpUrlOrNull() ?: error("Invalid v2rayN link content: $url")
|
||||
return SOCKSBean().apply {
|
||||
serverAddress = httpUrl.host
|
||||
serverPort = httpUrl.port
|
||||
username = httpUrl.username.takeIf { it != "null" } ?: ""
|
||||
password = httpUrl.password.takeIf { it != "null" } ?: ""
|
||||
if (link.contains("#")) {
|
||||
name = link.substringAfter("#").unUrlSafe()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val url = ("http://" + link.substringAfter("://")).toHttpUrlOrNull()
|
||||
?: error("Not supported: $link")
|
||||
|
||||
return SOCKSBean().apply {
|
||||
protocol = when {
|
||||
link.startsWith("socks4://") -> SOCKSBean.PROTOCOL_SOCKS4
|
||||
link.startsWith("socks4a://") -> SOCKSBean.PROTOCOL_SOCKS4A
|
||||
else -> SOCKSBean.PROTOCOL_SOCKS5
|
||||
}
|
||||
serverAddress = url.host
|
||||
serverPort = url.port
|
||||
username = url.username
|
||||
password = url.password
|
||||
name = url.fragment
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun SOCKSBean.toUri(): String {
|
||||
|
||||
val builder = HttpUrl.Builder().scheme("http").host(serverAddress).port(serverPort)
|
||||
if (!username.isNullOrBlank()) builder.username(username)
|
||||
if (!password.isNullOrBlank()) builder.password(password)
|
||||
if (!name.isNullOrBlank()) builder.encodedFragment(name.urlSafe())
|
||||
return builder.toLink("socks${protocolVersion()}")
|
||||
|
||||
}
|
||||
|
||||
fun SOCKSBean.toV2rayN(): String {
|
||||
|
||||
var link = ""
|
||||
if (username.isNotBlank()) {
|
||||
link += username.urlSafe() + ":" + password.urlSafe() + "@"
|
||||
}
|
||||
link += "$serverAddress:$serverPort"
|
||||
link = "socks://" + NGUtil.encode(link)
|
||||
if (name.isNotBlank()) {
|
||||
link += "#" + name.urlSafe()
|
||||
}
|
||||
|
||||
return link
|
||||
|
||||
}
|
||||
|
||||
fun buildSingBoxOutboundSocksBean(bean: SOCKSBean): SingBoxOptions.Outbound_SocksOptions {
|
||||
return SingBoxOptions.Outbound_SocksOptions().apply {
|
||||
type = "socks"
|
||||
server = bean.serverAddress
|
||||
server_port = bean.serverPort
|
||||
username = bean.username
|
||||
password = bean.password
|
||||
version = bean.protocolVersionName()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,98 @@
|
||||
package io.nekohasekai.sagernet.fmt.ssh;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.esotericsoftware.kryo.io.ByteBufferInput;
|
||||
import com.esotericsoftware.kryo.io.ByteBufferOutput;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import io.nekohasekai.sagernet.fmt.AbstractBean;
|
||||
import io.nekohasekai.sagernet.fmt.KryoConverters;
|
||||
|
||||
public class SSHBean extends AbstractBean {
|
||||
|
||||
public static final int AUTH_TYPE_NONE = 0;
|
||||
public static final int AUTH_TYPE_PASSWORD = 1;
|
||||
public static final int AUTH_TYPE_PRIVATE_KEY = 2;
|
||||
|
||||
public String username;
|
||||
public Integer authType;
|
||||
public String password;
|
||||
public String privateKey;
|
||||
public String privateKeyPassphrase;
|
||||
public String publicKey;
|
||||
|
||||
@Override
|
||||
public void initializeDefaultValues() {
|
||||
if (serverPort == null) serverPort = 22;
|
||||
|
||||
super.initializeDefaultValues();
|
||||
|
||||
if (username == null) username = "root";
|
||||
if (authType == null) authType = AUTH_TYPE_PASSWORD;
|
||||
if (password == null) password = "";
|
||||
if (privateKey == null) privateKey = "";
|
||||
if (privateKeyPassphrase == null) privateKeyPassphrase = "";
|
||||
if (publicKey == null) publicKey = "";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void serialize(ByteBufferOutput output) {
|
||||
output.writeInt(0);
|
||||
super.serialize(output);
|
||||
output.writeString(username);
|
||||
output.writeInt(authType);
|
||||
switch (authType) {
|
||||
case AUTH_TYPE_NONE:
|
||||
break;
|
||||
case AUTH_TYPE_PASSWORD:
|
||||
output.writeString(password);
|
||||
break;
|
||||
case AUTH_TYPE_PRIVATE_KEY:
|
||||
output.writeString(privateKey);
|
||||
output.writeString(privateKeyPassphrase);
|
||||
break;
|
||||
}
|
||||
output.writeString(publicKey);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deserialize(ByteBufferInput input) {
|
||||
int version = input.readInt();
|
||||
super.deserialize(input);
|
||||
username = input.readString();
|
||||
authType = input.readInt();
|
||||
switch (authType) {
|
||||
case AUTH_TYPE_NONE:
|
||||
break;
|
||||
case AUTH_TYPE_PASSWORD:
|
||||
password = input.readString();
|
||||
break;
|
||||
case AUTH_TYPE_PRIVATE_KEY:
|
||||
privateKey = input.readString();
|
||||
privateKeyPassphrase = input.readString();
|
||||
break;
|
||||
}
|
||||
publicKey = input.readString();
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Override
|
||||
public SSHBean clone() {
|
||||
return KryoConverters.deserialize(new SSHBean(), KryoConverters.serialize(this));
|
||||
}
|
||||
|
||||
public static final Creator<SSHBean> CREATOR = new CREATOR<SSHBean>() {
|
||||
@NonNull
|
||||
@Override
|
||||
public SSHBean newInstance() {
|
||||
return new SSHBean();
|
||||
}
|
||||
|
||||
@Override
|
||||
public SSHBean[] newArray(int size) {
|
||||
return new SSHBean[size];
|
||||
}
|
||||
};
|
||||
}
|
||||
22
app/src/main/java/io/nekohasekai/sagernet/fmt/ssh/SSHFmt.kt
Normal file
22
app/src/main/java/io/nekohasekai/sagernet/fmt/ssh/SSHFmt.kt
Normal file
@ -0,0 +1,22 @@
|
||||
package io.nekohasekai.sagernet.fmt.ssh
|
||||
|
||||
import moe.matsuri.nb4a.SingBoxOptions
|
||||
|
||||
fun buildSingBoxOutboundSSHBean(bean: SSHBean): SingBoxOptions.Outbound_SSHOptions {
|
||||
return SingBoxOptions.Outbound_SSHOptions().apply {
|
||||
type = "ssh"
|
||||
server = bean.serverAddress
|
||||
server_port = bean.serverPort
|
||||
user = bean.username
|
||||
host_key = bean.privateKey.split("\n")
|
||||
when (bean.authType) {
|
||||
SSHBean.AUTH_TYPE_PRIVATE_KEY -> {
|
||||
private_key = bean.privateKey
|
||||
private_key_passphrase = bean.privateKeyPassphrase
|
||||
}
|
||||
else -> {
|
||||
password = bean.password
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,69 @@
|
||||
package io.nekohasekai.sagernet.fmt.trojan;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.esotericsoftware.kryo.io.ByteBufferInput;
|
||||
import com.esotericsoftware.kryo.io.ByteBufferOutput;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import io.nekohasekai.sagernet.fmt.AbstractBean;
|
||||
import io.nekohasekai.sagernet.fmt.KryoConverters;
|
||||
import io.nekohasekai.sagernet.fmt.v2ray.StandardV2RayBean;
|
||||
|
||||
public class TrojanBean extends StandardV2RayBean {
|
||||
|
||||
public String password;
|
||||
|
||||
@Override
|
||||
public void initializeDefaultValues() {
|
||||
super.initializeDefaultValues();
|
||||
if (security == null || security.isEmpty()) security = "tls";
|
||||
if (password == null) password = "";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void serialize(ByteBufferOutput output) {
|
||||
output.writeInt(2);
|
||||
super.serialize(output);
|
||||
output.writeString(password);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deserialize(ByteBufferInput input) {
|
||||
int version = input.readInt();
|
||||
if (version >= 2) {
|
||||
super.deserialize(input); // StandardV2RayBean
|
||||
password = input.readString();
|
||||
} else {
|
||||
// From AbstractBean
|
||||
serverAddress = input.readString();
|
||||
serverPort = input.readInt();
|
||||
// From TrojanBean
|
||||
password = input.readString();
|
||||
security = input.readString();
|
||||
sni = input.readString();
|
||||
alpn = input.readString();
|
||||
if (version == 1) allowInsecure = input.readBoolean();
|
||||
}
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Override
|
||||
public TrojanBean clone() {
|
||||
return KryoConverters.deserialize(new TrojanBean(), KryoConverters.serialize(this));
|
||||
}
|
||||
|
||||
public static final Creator<TrojanBean> CREATOR = new CREATOR<TrojanBean>() {
|
||||
@NonNull
|
||||
@Override
|
||||
public TrojanBean newInstance() {
|
||||
return new TrojanBean();
|
||||
}
|
||||
|
||||
@Override
|
||||
public TrojanBean[] newArray(int size) {
|
||||
return new TrojanBean[size];
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
package io.nekohasekai.sagernet.fmt.trojan
|
||||
|
||||
import io.nekohasekai.sagernet.fmt.v2ray.parseDuckSoft
|
||||
import io.nekohasekai.sagernet.fmt.v2ray.toUri
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
|
||||
fun parseTrojan(server: String): TrojanBean {
|
||||
|
||||
val link = server.replace("trojan://", "https://").toHttpUrlOrNull()
|
||||
?: error("invalid trojan link $server")
|
||||
|
||||
return TrojanBean().apply {
|
||||
parseDuckSoft(link)
|
||||
link.queryParameter("allowInsecure")
|
||||
?.apply { if (this == "1" || this == "true") allowInsecure = true }
|
||||
link.queryParameter("peer")?.apply { if (this.isNotBlank()) sni = this }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun TrojanBean.toUri(): String {
|
||||
return toUri(true).replace("vmess://", "trojan://")
|
||||
}
|
||||
@ -0,0 +1,176 @@
|
||||
package io.nekohasekai.sagernet.fmt.trojan_go;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.esotericsoftware.kryo.io.ByteBufferInput;
|
||||
import com.esotericsoftware.kryo.io.ByteBufferOutput;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import io.nekohasekai.sagernet.fmt.AbstractBean;
|
||||
import io.nekohasekai.sagernet.fmt.KryoConverters;
|
||||
import moe.matsuri.nb4a.utils.JavaUtil;
|
||||
|
||||
public class TrojanGoBean extends AbstractBean {
|
||||
|
||||
/**
|
||||
* Trojan 的密码。
|
||||
* 不可省略,不能为空字符串,不建议含有非 ASCII 可打印字符。
|
||||
* 必须使用 encodeURIComponent 编码。
|
||||
*/
|
||||
public String password;
|
||||
|
||||
/**
|
||||
* 自定义 TLS 的 SNI。
|
||||
* 省略时默认与 trojan-host 同值。不得为空字符串。
|
||||
* <p>
|
||||
* 必须使用 encodeURIComponent 编码。
|
||||
*/
|
||||
public String sni;
|
||||
|
||||
/**
|
||||
* 传输类型。
|
||||
* 省略时默认为 original,但不可为空字符串。
|
||||
* 目前可选值只有 original 和 ws,未来可能会有 h2、h2+ws 等取值。
|
||||
* <p>
|
||||
* 当取值为 original 时,使用原始 Trojan 传输方式,无法方便通过 CDN。
|
||||
* 当取值为 ws 时,使用 wss 作为传输层。
|
||||
*/
|
||||
public String type;
|
||||
|
||||
/**
|
||||
* 自定义 HTTP Host 头。
|
||||
* 可以省略,省略时值同 trojan-host。
|
||||
* 可以为空字符串,但可能带来非预期情形。
|
||||
* <p>
|
||||
* 警告:若你的端口非标准端口(不是 80 / 443),RFC 标准规定 Host 应在主机名后附上端口号,例如 example.com:44333。至于是否遵守,请自行斟酌。
|
||||
* <p>
|
||||
* 必须使用 encodeURIComponent 编码。
|
||||
*/
|
||||
public String host;
|
||||
|
||||
/**
|
||||
* 当传输类型 type 取 ws、h2、h2+ws 时,此项有效。
|
||||
* 不可省略,不可为空。
|
||||
* 必须以 / 开头。
|
||||
* 可以使用 URL 中的 & # ? 等字符,但应当是合法的 URL 路径。
|
||||
* <p>
|
||||
* 必须使用 encodeURIComponent 编码。
|
||||
*/
|
||||
public String path;
|
||||
|
||||
/**
|
||||
* 用于保证 Trojan 流量密码学安全的加密层。
|
||||
* 可省略,默认为 none,即不使用加密。
|
||||
* 不可以为空字符串。
|
||||
* <p>
|
||||
* 必须使用 encodeURIComponent 编码。
|
||||
* <p>
|
||||
* 使用 Shadowsocks 算法进行流量加密时,其格式为:
|
||||
* <p>
|
||||
* ss;method:password
|
||||
* <p>
|
||||
* 其中 ss 是固定内容,method 是加密方法,必须为下列之一:
|
||||
* <p>
|
||||
* aes-128-gcm
|
||||
* aes-256-gcm
|
||||
* chacha20-ietf-poly1305
|
||||
*/
|
||||
public String encryption;
|
||||
|
||||
/**
|
||||
* 额外的插件选项。本字段保留。
|
||||
* 可省略,但不可以为空字符串。
|
||||
*/
|
||||
// not used in NB4A
|
||||
public String plugin;
|
||||
|
||||
// ---
|
||||
|
||||
public Boolean allowInsecure;
|
||||
|
||||
@Override
|
||||
public void initializeDefaultValues() {
|
||||
super.initializeDefaultValues();
|
||||
|
||||
if (password == null) password = "";
|
||||
if (sni == null) sni = "";
|
||||
if (JavaUtil.isNullOrBlank(type)) type = "original";
|
||||
if (host == null) host = "";
|
||||
if (path == null) path = "";
|
||||
if (JavaUtil.isNullOrBlank(encryption)) encryption = "none";
|
||||
if (plugin == null) plugin = "";
|
||||
if (allowInsecure == null) allowInsecure = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void serialize(ByteBufferOutput output) {
|
||||
output.writeInt(1);
|
||||
super.serialize(output);
|
||||
output.writeString(password);
|
||||
output.writeString(sni);
|
||||
output.writeString(type);
|
||||
//noinspection SwitchStatementWithTooFewBranches
|
||||
switch (type) {
|
||||
case "ws": {
|
||||
output.writeString(host);
|
||||
output.writeString(path);
|
||||
break;
|
||||
}
|
||||
}
|
||||
output.writeString(encryption);
|
||||
output.writeString(plugin);
|
||||
output.writeBoolean(allowInsecure);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deserialize(ByteBufferInput input) {
|
||||
int version = input.readInt();
|
||||
super.deserialize(input);
|
||||
|
||||
password = input.readString();
|
||||
sni = input.readString();
|
||||
type = input.readString();
|
||||
//noinspection SwitchStatementWithTooFewBranches
|
||||
switch (type) {
|
||||
case "ws": {
|
||||
host = input.readString();
|
||||
path = input.readString();
|
||||
break;
|
||||
}
|
||||
}
|
||||
encryption = input.readString();
|
||||
plugin = input.readString();
|
||||
if (version >= 1) {
|
||||
allowInsecure = input.readBoolean();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void applyFeatureSettings(AbstractBean other) {
|
||||
if (!(other instanceof TrojanGoBean)) return;
|
||||
TrojanGoBean bean = ((TrojanGoBean) other);
|
||||
if (allowInsecure) {
|
||||
bean.allowInsecure = true;
|
||||
}
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Override
|
||||
public TrojanGoBean clone() {
|
||||
return KryoConverters.deserialize(new TrojanGoBean(), KryoConverters.serialize(this));
|
||||
}
|
||||
|
||||
public static final Creator<TrojanGoBean> CREATOR = new CREATOR<TrojanGoBean>() {
|
||||
@NonNull
|
||||
@Override
|
||||
public TrojanGoBean newInstance() {
|
||||
return new TrojanGoBean();
|
||||
}
|
||||
|
||||
@Override
|
||||
public TrojanGoBean[] newArray(int size) {
|
||||
return new TrojanGoBean[size];
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,162 @@
|
||||
package io.nekohasekai.sagernet.fmt.trojan_go
|
||||
|
||||
import io.nekohasekai.sagernet.IPv6Mode
|
||||
import io.nekohasekai.sagernet.database.DataStore
|
||||
import io.nekohasekai.sagernet.fmt.LOCALHOST
|
||||
import io.nekohasekai.sagernet.ktx.*
|
||||
import moe.matsuri.nb4a.Protocols
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
fun parseTrojanGo(server: String): TrojanGoBean {
|
||||
val link = server.replace("trojan-go://", "https://").toHttpUrlOrNull() ?: error(
|
||||
"invalid trojan-link link $server"
|
||||
)
|
||||
return TrojanGoBean().apply {
|
||||
serverAddress = link.host
|
||||
serverPort = link.port
|
||||
password = link.username
|
||||
link.queryParameter("sni")?.let {
|
||||
sni = it
|
||||
}
|
||||
link.queryParameter("type")?.let { lType ->
|
||||
type = lType
|
||||
|
||||
when (type) {
|
||||
"ws" -> {
|
||||
link.queryParameter("host")?.let {
|
||||
host = it
|
||||
}
|
||||
link.queryParameter("path")?.let {
|
||||
path = it
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
}
|
||||
}
|
||||
}
|
||||
link.queryParameter("encryption")?.let {
|
||||
encryption = it
|
||||
}
|
||||
link.queryParameter("plugin")?.let {
|
||||
plugin = it
|
||||
}
|
||||
link.fragment.takeIf { !it.isNullOrBlank() }?.let {
|
||||
name = it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun TrojanGoBean.toUri(): String {
|
||||
val builder = linkBuilder().username(password).host(serverAddress).port(serverPort)
|
||||
if (sni.isNotBlank()) {
|
||||
builder.addQueryParameter("sni", sni)
|
||||
}
|
||||
if (type.isNotBlank() && type != "original") {
|
||||
builder.addQueryParameter("type", type)
|
||||
|
||||
when (type) {
|
||||
"ws" -> {
|
||||
if (host.isNotBlank()) {
|
||||
builder.addQueryParameter("host", host)
|
||||
}
|
||||
if (path.isNotBlank()) {
|
||||
builder.addQueryParameter("path", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (type.isNotBlank() && type != "none") {
|
||||
builder.addQueryParameter("encryption", encryption)
|
||||
}
|
||||
if (plugin.isNotBlank()) {
|
||||
builder.addQueryParameter("plugin", plugin)
|
||||
}
|
||||
|
||||
if (name.isNotBlank()) {
|
||||
builder.encodedFragment(name.urlSafe())
|
||||
}
|
||||
|
||||
return builder.toLink("trojan-go")
|
||||
}
|
||||
|
||||
fun TrojanGoBean.buildTrojanGoConfig(port: Int): String {
|
||||
return JSONObject().apply {
|
||||
put("run_type", "client")
|
||||
put("local_addr", LOCALHOST)
|
||||
put("local_port", port)
|
||||
put("remote_addr", finalAddress)
|
||||
put("remote_port", finalPort)
|
||||
put("password", JSONArray().apply {
|
||||
put(password)
|
||||
})
|
||||
put("log_level", if (DataStore.enableLog) 0 else 2)
|
||||
if (Protocols.shouldEnableMux("trojan-go")) put("mux", JSONObject().apply {
|
||||
put("enabled", true)
|
||||
put("concurrency", DataStore.muxConcurrency)
|
||||
})
|
||||
put("tcp", JSONObject().apply {
|
||||
put("prefer_ipv4", DataStore.ipv6Mode <= IPv6Mode.ENABLE)
|
||||
})
|
||||
|
||||
when (type) {
|
||||
"original" -> {
|
||||
}
|
||||
"ws" -> put("websocket", JSONObject().apply {
|
||||
put("enabled", true)
|
||||
put("host", host)
|
||||
put("path", path)
|
||||
})
|
||||
}
|
||||
|
||||
if (sni.isBlank() && finalAddress == LOCALHOST && !serverAddress.isIpAddress()) {
|
||||
sni = serverAddress
|
||||
}
|
||||
|
||||
put("ssl", JSONObject().apply {
|
||||
if (sni.isNotBlank()) put("sni", sni)
|
||||
if (allowInsecure) put("verify", false)
|
||||
})
|
||||
|
||||
when {
|
||||
encryption == "none" -> {
|
||||
}
|
||||
encryption.startsWith("ss;") -> put("shadowsocks", JSONObject().apply {
|
||||
put("enabled", true)
|
||||
put("method", encryption.substringAfter(";").substringBefore(":"))
|
||||
put("password", encryption.substringAfter(":"))
|
||||
})
|
||||
}
|
||||
}.toStringPretty()
|
||||
}
|
||||
|
||||
fun JSONObject.parseTrojanGo(): TrojanGoBean {
|
||||
return TrojanGoBean().applyDefaultValues().apply {
|
||||
serverAddress = optString("remote_addr", serverAddress)
|
||||
serverPort = optInt("remote_port", serverPort)
|
||||
when (val pass = get("password")) {
|
||||
is String -> {
|
||||
password = pass
|
||||
}
|
||||
is List<*> -> {
|
||||
password = pass[0] as String
|
||||
}
|
||||
}
|
||||
optJSONArray("ssl")?.apply {
|
||||
sni = optString("sni", sni)
|
||||
}
|
||||
optJSONArray("websocket")?.apply {
|
||||
if (optBoolean("enabled", false)) {
|
||||
type = "ws"
|
||||
host = optString("host", host)
|
||||
path = optString("path", path)
|
||||
}
|
||||
}
|
||||
optJSONArray("shadowsocks")?.apply {
|
||||
if (optBoolean("enabled", false)) {
|
||||
encryption = "ss;${optString("method", "")}:${optString("password", "")}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,97 @@
|
||||
package io.nekohasekai.sagernet.fmt.tuic;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.esotericsoftware.kryo.io.ByteBufferInput;
|
||||
import com.esotericsoftware.kryo.io.ByteBufferOutput;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import io.nekohasekai.sagernet.fmt.AbstractBean;
|
||||
import io.nekohasekai.sagernet.fmt.KryoConverters;
|
||||
|
||||
public class TuicBean extends AbstractBean {
|
||||
|
||||
public String token;
|
||||
public String caText;
|
||||
public String udpRelayMode;
|
||||
public String congestionController;
|
||||
public String alpn;
|
||||
public Boolean disableSNI;
|
||||
public Boolean reduceRTT;
|
||||
public Integer mtu;
|
||||
public String sni;
|
||||
public Boolean fastConnect;
|
||||
public Boolean allowInsecure;
|
||||
|
||||
@Override
|
||||
public void initializeDefaultValues() {
|
||||
super.initializeDefaultValues();
|
||||
if (token == null) token = "";
|
||||
if (caText == null) caText = "";
|
||||
if (udpRelayMode == null) udpRelayMode = "native";
|
||||
if (congestionController == null) congestionController = "cubic";
|
||||
if (alpn == null) alpn = "";
|
||||
if (disableSNI == null) disableSNI = false;
|
||||
if (reduceRTT == null) reduceRTT = false;
|
||||
if (mtu == null) mtu = 1400;
|
||||
if (sni == null) sni = "";
|
||||
if (fastConnect == null) fastConnect = false;
|
||||
if (allowInsecure == null) allowInsecure = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void serialize(ByteBufferOutput output) {
|
||||
output.writeInt(1);
|
||||
super.serialize(output);
|
||||
output.writeString(token);
|
||||
output.writeString(caText);
|
||||
output.writeString(udpRelayMode);
|
||||
output.writeString(congestionController);
|
||||
output.writeString(alpn);
|
||||
output.writeBoolean(disableSNI);
|
||||
output.writeBoolean(reduceRTT);
|
||||
output.writeInt(mtu);
|
||||
output.writeString(sni);
|
||||
output.writeBoolean(fastConnect);
|
||||
output.writeBoolean(allowInsecure);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deserialize(ByteBufferInput input) {
|
||||
int version = input.readInt();
|
||||
super.deserialize(input);
|
||||
token = input.readString();
|
||||
caText = input.readString();
|
||||
udpRelayMode = input.readString();
|
||||
congestionController = input.readString();
|
||||
alpn = input.readString();
|
||||
disableSNI = input.readBoolean();
|
||||
reduceRTT = input.readBoolean();
|
||||
mtu = input.readInt();
|
||||
sni = input.readString();
|
||||
if (version >= 1) {
|
||||
fastConnect = input.readBoolean();
|
||||
allowInsecure = input.readBoolean();
|
||||
}
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Override
|
||||
public TuicBean clone() {
|
||||
return KryoConverters.deserialize(new TuicBean(), KryoConverters.serialize(this));
|
||||
}
|
||||
|
||||
public static final Creator<TuicBean> CREATOR = new CREATOR<TuicBean>() {
|
||||
@NonNull
|
||||
@Override
|
||||
public TuicBean newInstance() {
|
||||
return new TuicBean();
|
||||
}
|
||||
|
||||
@Override
|
||||
public TuicBean[] newArray(int size) {
|
||||
return new TuicBean[size];
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,64 @@
|
||||
package io.nekohasekai.sagernet.fmt.tuic
|
||||
|
||||
import io.nekohasekai.sagernet.database.DataStore
|
||||
import io.nekohasekai.sagernet.fmt.LOCALHOST
|
||||
import io.nekohasekai.sagernet.ktx.isIpAddress
|
||||
import io.nekohasekai.sagernet.ktx.toStringPretty
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import moe.matsuri.nb4a.plugin.Plugins
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.net.InetAddress
|
||||
|
||||
fun TuicBean.buildTuicConfig(port: Int, cacheFile: (() -> File)?): String {
|
||||
if (Plugins.isUsingMatsuriExe("tuic-plugin")) {
|
||||
if (!serverAddress.isIpAddress()) {
|
||||
runBlocking {
|
||||
finalAddress = withContext(Dispatchers.IO) {
|
||||
InetAddress.getAllByName(serverAddress)
|
||||
}?.firstOrNull()?.hostAddress ?: "127.0.0.1"
|
||||
// TODO network on main thread, tuic don't support "sni"
|
||||
}
|
||||
}
|
||||
}
|
||||
return JSONObject().apply {
|
||||
put("relay", JSONObject().apply {
|
||||
if (sni.isNotBlank()) {
|
||||
put("server", sni)
|
||||
put("ip", finalAddress)
|
||||
} else if (serverAddress.isIpAddress()) {
|
||||
put("server", finalAddress)
|
||||
} else {
|
||||
put("server", serverAddress)
|
||||
put("ip", finalAddress)
|
||||
}
|
||||
put("port", finalPort)
|
||||
put("token", token)
|
||||
|
||||
if (caText.isNotBlank() && cacheFile != null) {
|
||||
val caFile = cacheFile()
|
||||
caFile.writeText(caText)
|
||||
put("certificates", JSONArray(listOf(caFile.absolutePath)))
|
||||
}
|
||||
|
||||
put("udp_relay_mode", udpRelayMode)
|
||||
if (alpn.isNotBlank()) {
|
||||
put("alpn", JSONArray(alpn.split("\n")))
|
||||
}
|
||||
put("congestion_controller", congestionController)
|
||||
put("disable_sni", disableSNI)
|
||||
put("reduce_rtt", reduceRTT)
|
||||
put("max_udp_relay_packet_size", mtu)
|
||||
if (fastConnect) put("fast_connect", true)
|
||||
if (allowInsecure) put("insecure", true)
|
||||
})
|
||||
put("local", JSONObject().apply {
|
||||
put("ip", LOCALHOST)
|
||||
put("port", port)
|
||||
})
|
||||
put("log_level", if (DataStore.enableLog) "debug" else "info")
|
||||
}.toStringPretty()
|
||||
}
|
||||
@ -0,0 +1,194 @@
|
||||
package io.nekohasekai.sagernet.fmt.v2ray;
|
||||
|
||||
import com.esotericsoftware.kryo.io.ByteBufferInput;
|
||||
import com.esotericsoftware.kryo.io.ByteBufferOutput;
|
||||
|
||||
import io.nekohasekai.sagernet.fmt.AbstractBean;
|
||||
import moe.matsuri.nb4a.utils.JavaUtil;
|
||||
|
||||
public abstract class StandardV2RayBean extends AbstractBean {
|
||||
|
||||
public String uuid;
|
||||
public String encryption; // or VLESS flow
|
||||
|
||||
//////// End of VMess & VLESS ////////
|
||||
|
||||
// "V2Ray Transport" tcp/http/ws/quic/grpc
|
||||
public String type;
|
||||
|
||||
public String host;
|
||||
|
||||
public String path;
|
||||
|
||||
// --------------------------------------- tls?
|
||||
|
||||
public String security;
|
||||
|
||||
public String sni;
|
||||
|
||||
public String alpn;
|
||||
|
||||
public String utlsFingerprint;
|
||||
|
||||
public Boolean allowInsecure;
|
||||
|
||||
// --------------------------------------- reality
|
||||
|
||||
|
||||
public String realityPubKey;
|
||||
|
||||
public String realityShortId;
|
||||
|
||||
|
||||
// --------------------------------------- //
|
||||
|
||||
public Integer wsMaxEarlyData;
|
||||
public String earlyDataHeaderName;
|
||||
|
||||
public String certificates;
|
||||
|
||||
// --------------------------------------- //
|
||||
|
||||
public Integer packetEncoding; // 1:packet 2:xudp
|
||||
|
||||
@Override
|
||||
public void initializeDefaultValues() {
|
||||
super.initializeDefaultValues();
|
||||
|
||||
if (JavaUtil.isNullOrBlank(uuid)) uuid = "";
|
||||
|
||||
if (JavaUtil.isNullOrBlank(type)) type = "tcp";
|
||||
else if ("h2".equals(type)) type = "http";
|
||||
|
||||
if (JavaUtil.isNullOrBlank(host)) host = "";
|
||||
if (JavaUtil.isNullOrBlank(path)) path = "";
|
||||
|
||||
if (JavaUtil.isNullOrBlank(security)) security = "none";
|
||||
if (JavaUtil.isNullOrBlank(sni)) sni = "";
|
||||
if (JavaUtil.isNullOrBlank(alpn)) alpn = "";
|
||||
|
||||
if (JavaUtil.isNullOrBlank(certificates)) certificates = "";
|
||||
if (JavaUtil.isNullOrBlank(earlyDataHeaderName)) earlyDataHeaderName = "";
|
||||
if (JavaUtil.isNullOrBlank(utlsFingerprint)) utlsFingerprint = "";
|
||||
|
||||
if (wsMaxEarlyData == null) wsMaxEarlyData = 0;
|
||||
if (allowInsecure == null) allowInsecure = false;
|
||||
if (packetEncoding == null) packetEncoding = 0;
|
||||
|
||||
if (realityPubKey == null) realityPubKey = "";
|
||||
if (realityShortId == null) realityShortId = "";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void serialize(ByteBufferOutput output) {
|
||||
output.writeInt(0);
|
||||
super.serialize(output);
|
||||
|
||||
output.writeString(uuid);
|
||||
output.writeString(encryption);
|
||||
if (this instanceof VMessBean) {
|
||||
output.writeInt(((VMessBean) this).alterId);
|
||||
}
|
||||
|
||||
output.writeString(type);
|
||||
switch (type) {
|
||||
case "tcp":
|
||||
case "quic": {
|
||||
break;
|
||||
}
|
||||
case "ws": {
|
||||
output.writeString(host);
|
||||
output.writeString(path);
|
||||
output.writeInt(wsMaxEarlyData);
|
||||
output.writeString(earlyDataHeaderName);
|
||||
break;
|
||||
}
|
||||
case "http": {
|
||||
output.writeString(host);
|
||||
output.writeString(path);
|
||||
break;
|
||||
}
|
||||
case "grpc": {
|
||||
output.writeString(path);
|
||||
}
|
||||
}
|
||||
|
||||
output.writeString(security);
|
||||
if ("tls".equals(security)) {
|
||||
output.writeString(sni);
|
||||
output.writeString(alpn);
|
||||
output.writeString(certificates);
|
||||
output.writeBoolean(allowInsecure);
|
||||
output.writeString(utlsFingerprint);
|
||||
output.writeString(realityPubKey);
|
||||
output.writeString(realityShortId);
|
||||
}
|
||||
|
||||
output.writeInt(packetEncoding);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deserialize(ByteBufferInput input) {
|
||||
int version = input.readInt();
|
||||
super.deserialize(input);
|
||||
uuid = input.readString();
|
||||
encryption = input.readString();
|
||||
if (this instanceof VMessBean) {
|
||||
((VMessBean) this).alterId = input.readInt();
|
||||
}
|
||||
|
||||
type = input.readString();
|
||||
switch (type) {
|
||||
case "tcp":
|
||||
case "quic": {
|
||||
break;
|
||||
}
|
||||
case "ws": {
|
||||
host = input.readString();
|
||||
path = input.readString();
|
||||
wsMaxEarlyData = input.readInt();
|
||||
earlyDataHeaderName = input.readString();
|
||||
break;
|
||||
}
|
||||
case "http": {
|
||||
host = input.readString();
|
||||
path = input.readString();
|
||||
break;
|
||||
}
|
||||
case "grpc": {
|
||||
path = input.readString();
|
||||
}
|
||||
}
|
||||
|
||||
security = input.readString();
|
||||
if ("tls".equals(security)) {
|
||||
sni = input.readString();
|
||||
alpn = input.readString();
|
||||
certificates = input.readString();
|
||||
allowInsecure = input.readBoolean();
|
||||
utlsFingerprint = input.readString();
|
||||
realityPubKey = input.readString();
|
||||
realityShortId = input.readString();
|
||||
}
|
||||
|
||||
packetEncoding = input.readInt();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void applyFeatureSettings(AbstractBean other) {
|
||||
if (!(other instanceof StandardV2RayBean)) return;
|
||||
StandardV2RayBean bean = ((StandardV2RayBean) other);
|
||||
bean.allowInsecure = allowInsecure;
|
||||
bean.utlsFingerprint = utlsFingerprint;
|
||||
bean.realityPubKey = realityPubKey;
|
||||
bean.realityShortId = realityShortId;
|
||||
}
|
||||
|
||||
public boolean isVLESS() {
|
||||
if (this instanceof VMessBean) {
|
||||
return ((VMessBean) this).alterId == -1;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
608
app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/V2RayFmt.kt
Normal file
608
app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/V2RayFmt.kt
Normal file
@ -0,0 +1,608 @@
|
||||
package io.nekohasekai.sagernet.fmt.v2ray
|
||||
|
||||
import com.google.gson.Gson
|
||||
import io.nekohasekai.sagernet.fmt.http.HttpBean
|
||||
import io.nekohasekai.sagernet.fmt.trojan.TrojanBean
|
||||
import moe.matsuri.nb4a.SingBoxOptions.*
|
||||
import io.nekohasekai.sagernet.ktx.*
|
||||
import moe.matsuri.nb4a.utils.NGUtil
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.json.JSONObject
|
||||
|
||||
fun StandardV2RayBean.isTLS(): Boolean {
|
||||
return security == "tls"
|
||||
}
|
||||
|
||||
fun StandardV2RayBean.setTLS(boolean: Boolean) {
|
||||
security = if (boolean) "tls" else ""
|
||||
}
|
||||
|
||||
fun parseV2Ray(link: String): StandardV2RayBean {
|
||||
// Try parse stupid formats first
|
||||
|
||||
if (!link.contains("?")) {
|
||||
try {
|
||||
return parseV2RayN(link)
|
||||
} catch (e: Exception) {
|
||||
Logs.i("try v2rayN: " + e.readableMessage)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return tryResolveVmess4Kitsunebi(link)
|
||||
} catch (e: Exception) {
|
||||
Logs.i("try Kitsunebi: " + e.readableMessage)
|
||||
}
|
||||
|
||||
// "std" format
|
||||
|
||||
val bean = VMessBean().apply { if (link.startsWith("vless://")) alterId = -1 }
|
||||
val url = link.replace("vmess://", "https://").replace("vless://", "https://").toHttpUrl()
|
||||
|
||||
if (url.password.isNotBlank()) {
|
||||
// https://github.com/v2fly/v2fly-github-io/issues/26 (rarely use)
|
||||
bean.serverAddress = url.host
|
||||
bean.serverPort = url.port
|
||||
bean.name = url.fragment
|
||||
|
||||
var protocol = url.username
|
||||
bean.type = protocol
|
||||
bean.alterId = url.password.substringAfterLast('-').toInt()
|
||||
bean.uuid = url.password.substringBeforeLast('-')
|
||||
|
||||
if (protocol.endsWith("+tls")) {
|
||||
bean.security = "tls"
|
||||
protocol = protocol.substring(0, protocol.length - 4)
|
||||
|
||||
url.queryParameter("tlsServerName")?.let {
|
||||
if (it.isNotBlank()) {
|
||||
bean.sni = it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (protocol) {
|
||||
// "tcp" -> {
|
||||
// url.queryParameter("type")?.let { type ->
|
||||
// if (type == "http") {
|
||||
// bean.headerType = "http"
|
||||
// url.queryParameter("host")?.let {
|
||||
// bean.host = it
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
"http" -> {
|
||||
url.queryParameter("path")?.let {
|
||||
bean.path = it
|
||||
}
|
||||
url.queryParameter("host")?.let {
|
||||
bean.host = it.split("|").joinToString(",")
|
||||
}
|
||||
}
|
||||
"ws" -> {
|
||||
url.queryParameter("path")?.let {
|
||||
bean.path = it
|
||||
}
|
||||
url.queryParameter("host")?.let {
|
||||
bean.host = it
|
||||
}
|
||||
}
|
||||
"grpc" -> {
|
||||
url.queryParameter("serviceName")?.let {
|
||||
bean.path = it
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// also vless format
|
||||
bean.parseDuckSoft(url)
|
||||
}
|
||||
|
||||
return bean
|
||||
}
|
||||
|
||||
// https://github.com/XTLS/Xray-core/issues/91
|
||||
// NO allowInsecure
|
||||
fun StandardV2RayBean.parseDuckSoft(url: HttpUrl) {
|
||||
serverAddress = url.host
|
||||
serverPort = url.port
|
||||
name = url.fragment
|
||||
|
||||
if (this is TrojanBean) {
|
||||
password = url.username
|
||||
} else {
|
||||
uuid = url.username
|
||||
}
|
||||
|
||||
if (url.pathSegments.size > 1 || url.pathSegments[0].isNotBlank()) {
|
||||
path = url.pathSegments.joinToString("/")
|
||||
}
|
||||
|
||||
type = url.queryParameter("type") ?: "tcp"
|
||||
security = url.queryParameter("security")
|
||||
if (security == null) {
|
||||
security = if (this is TrojanBean) {
|
||||
"tls"
|
||||
} else {
|
||||
"none"
|
||||
}
|
||||
}
|
||||
|
||||
when (security) {
|
||||
"tls" -> {
|
||||
url.queryParameter("sni")?.let {
|
||||
sni = it
|
||||
}
|
||||
url.queryParameter("alpn")?.let {
|
||||
alpn = it
|
||||
}
|
||||
url.queryParameter("cert")?.let {
|
||||
certificates = it
|
||||
}
|
||||
}
|
||||
}
|
||||
when (type) {
|
||||
"http" -> {
|
||||
url.queryParameter("host")?.let {
|
||||
host = it
|
||||
}
|
||||
url.queryParameter("path")?.let {
|
||||
path = it
|
||||
}
|
||||
}
|
||||
"ws" -> {
|
||||
url.queryParameter("host")?.let {
|
||||
host = it
|
||||
}
|
||||
url.queryParameter("path")?.let {
|
||||
path = it
|
||||
}
|
||||
url.queryParameter("ed")?.let { ed ->
|
||||
wsMaxEarlyData = ed.toInt()
|
||||
|
||||
url.queryParameter("eh")?.let {
|
||||
earlyDataHeaderName = it
|
||||
}
|
||||
}
|
||||
}
|
||||
"grpc" -> {
|
||||
url.queryParameter("serviceName")?.let {
|
||||
path = it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
url.queryParameter("packetEncoding")?.let {
|
||||
when (it) {
|
||||
"packet" -> packetEncoding = 1
|
||||
"xudp" -> packetEncoding = 2
|
||||
}
|
||||
}
|
||||
|
||||
url.queryParameter("flow")?.let {
|
||||
if (isVLESS && it.contains("vision")) {
|
||||
encryption = it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 不确定是谁的格式
|
||||
private fun tryResolveVmess4Kitsunebi(server: String): VMessBean {
|
||||
// vmess://YXV0bzo1YWY1ZDBlYy02ZWEwLTNjNDMtOTNkYi1jYTMwMDg1MDNiZGJAMTgzLjIzMi41Ni4xNjE6MTIwMg
|
||||
// ?remarks=*%F0%9F%87%AF%F0%9F%87%B5JP%20-355%20TG@moon365free&obfsParam=%7B%22Host%22:%22183.232.56.161%22%7D&path=/v2ray&obfs=websocket&alterId=0
|
||||
|
||||
var result = server.replace("vmess://", "")
|
||||
val indexSplit = result.indexOf("?")
|
||||
if (indexSplit > 0) {
|
||||
result = result.substring(0, indexSplit)
|
||||
}
|
||||
result = NGUtil.decode(result)
|
||||
|
||||
val arr1 = result.split('@')
|
||||
if (arr1.count() != 2) {
|
||||
throw IllegalStateException("invalid kitsunebi format")
|
||||
}
|
||||
val arr21 = arr1[0].split(':')
|
||||
val arr22 = arr1[1].split(':')
|
||||
if (arr21.count() != 2) {
|
||||
throw IllegalStateException("invalid kitsunebi format")
|
||||
}
|
||||
|
||||
return VMessBean().apply {
|
||||
serverAddress = arr22[0]
|
||||
serverPort = NGUtil.parseInt(arr22[1])
|
||||
uuid = arr21[1]
|
||||
encryption = arr21[0]
|
||||
if (indexSplit < 0) return@apply
|
||||
|
||||
val url = ("https://localhost/path?" + server.substringAfter("?")).toHttpUrl()
|
||||
url.queryParameter("remarks")?.apply { name = this }
|
||||
url.queryParameter("alterId")?.apply { alterId = this.toInt() }
|
||||
url.queryParameter("path")?.apply { path = this }
|
||||
url.queryParameter("tls")?.apply { security = "tls" }
|
||||
url.queryParameter("allowInsecure")
|
||||
?.apply { if (this == "1" || this == "true") allowInsecure = true }
|
||||
url.queryParameter("obfs")?.apply {
|
||||
type = this.replace("websocket", "ws").replace("none", "tcp")
|
||||
if (type == "ws") {
|
||||
url.queryParameter("obfsParam")?.apply {
|
||||
if (this.startsWith("{")) {
|
||||
host = JSONObject(this).getStr("Host")
|
||||
} else if (security == "tls") {
|
||||
sni = this
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SagerNet's
|
||||
// Do not support some format and then throw exception
|
||||
fun parseV2RayN(link: String): VMessBean {
|
||||
val result = link.substringAfter("vmess://").decodeBase64UrlSafe()
|
||||
if (result.contains("= vmess")) {
|
||||
return parseCsvVMess(result)
|
||||
}
|
||||
val bean = VMessBean()
|
||||
val json = JSONObject(result)
|
||||
|
||||
bean.serverAddress = json.getStr("add") ?: ""
|
||||
bean.serverPort = json.getIntNya("port") ?: 1080
|
||||
bean.encryption = json.getStr("scy") ?: ""
|
||||
bean.uuid = json.getStr("id") ?: ""
|
||||
bean.alterId = json.getIntNya("aid") ?: 0
|
||||
bean.type = json.getStr("net") ?: ""
|
||||
bean.host = json.getStr("host") ?: ""
|
||||
bean.path = json.getStr("path") ?: ""
|
||||
|
||||
when (bean.type) {
|
||||
"grpc" -> {
|
||||
bean.path = bean.path
|
||||
}
|
||||
}
|
||||
|
||||
bean.name = json.getStr("ps") ?: ""
|
||||
bean.sni = json.getStr("sni") ?: bean.host
|
||||
bean.security = json.getStr("tls")
|
||||
|
||||
if (json.optInt("v", 2) < 2) {
|
||||
when (bean.type) {
|
||||
"ws" -> {
|
||||
var path = ""
|
||||
var host = ""
|
||||
val lstParameter = bean.host.split(";")
|
||||
if (lstParameter.isNotEmpty()) {
|
||||
path = lstParameter[0].trim()
|
||||
}
|
||||
if (lstParameter.size > 1) {
|
||||
path = lstParameter[0].trim()
|
||||
host = lstParameter[1].trim()
|
||||
}
|
||||
bean.path = path
|
||||
bean.host = host
|
||||
}
|
||||
"h2" -> {
|
||||
var path = ""
|
||||
var host = ""
|
||||
val lstParameter = bean.host.split(";")
|
||||
if (lstParameter.isNotEmpty()) {
|
||||
path = lstParameter[0].trim()
|
||||
}
|
||||
if (lstParameter.size > 1) {
|
||||
path = lstParameter[0].trim()
|
||||
host = lstParameter[1].trim()
|
||||
}
|
||||
bean.path = path
|
||||
bean.host = host
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bean
|
||||
|
||||
}
|
||||
|
||||
private fun parseCsvVMess(csv: String): VMessBean {
|
||||
|
||||
val args = csv.split(",")
|
||||
|
||||
val bean = VMessBean()
|
||||
|
||||
bean.serverAddress = args[1]
|
||||
bean.serverPort = args[2].toInt()
|
||||
bean.encryption = args[3]
|
||||
bean.uuid = args[4].replace("\"", "")
|
||||
|
||||
args.subList(5, args.size).forEach {
|
||||
|
||||
when {
|
||||
it == "over-tls=true" -> bean.security = "tls"
|
||||
it.startsWith("tls-host=") -> bean.host = it.substringAfter("=")
|
||||
it.startsWith("obfs=") -> bean.type = it.substringAfter("=")
|
||||
it.startsWith("obfs-path=") || it.contains("Host:") -> {
|
||||
runCatching {
|
||||
bean.path = it.substringAfter("obfs-path=\"").substringBefore("\"obfs")
|
||||
}
|
||||
runCatching {
|
||||
bean.host = it.substringAfter("Host:").substringBefore("[")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return bean
|
||||
|
||||
}
|
||||
|
||||
data class VmessQRCode(
|
||||
var v: String = "",
|
||||
var ps: String = "",
|
||||
var add: String = "",
|
||||
var port: String = "",
|
||||
var id: String = "",
|
||||
var aid: String = "0",
|
||||
var scy: String = "",
|
||||
var net: String = "",
|
||||
var type: String = "",
|
||||
var host: String = "",
|
||||
var path: String = "",
|
||||
var tls: String = "",
|
||||
var sni: String = "",
|
||||
var alpn: String = ""
|
||||
)
|
||||
|
||||
fun VMessBean.toV2rayN(): String {
|
||||
if (isVLESS) return toUri(true)
|
||||
return "vmess://" + VmessQRCode().apply {
|
||||
v = "2"
|
||||
ps = this@toV2rayN.name
|
||||
add = this@toV2rayN.serverAddress
|
||||
port = this@toV2rayN.serverPort.toString()
|
||||
id = this@toV2rayN.uuid
|
||||
aid = this@toV2rayN.alterId.toString()
|
||||
net = this@toV2rayN.type
|
||||
host = this@toV2rayN.host
|
||||
path = this@toV2rayN.path
|
||||
|
||||
when (net) {
|
||||
"grpc" -> {
|
||||
path = this@toV2rayN.path
|
||||
}
|
||||
}
|
||||
|
||||
tls = if (this@toV2rayN.security == "tls") "tls" else ""
|
||||
sni = this@toV2rayN.sni
|
||||
scy = this@toV2rayN.encryption
|
||||
}.let {
|
||||
NGUtil.encode(Gson().toJson(it))
|
||||
}
|
||||
}
|
||||
|
||||
fun StandardV2RayBean.toUri(standard: Boolean = true): String {
|
||||
if (this is VMessBean && alterId > 0) return toV2rayN()
|
||||
|
||||
val builder = linkBuilder()
|
||||
.username(if (this is TrojanBean) password else uuid)
|
||||
.host(serverAddress)
|
||||
.port(serverPort)
|
||||
.addQueryParameter("type", type)
|
||||
if (this !is TrojanBean) builder.addQueryParameter("encryption", encryption)
|
||||
|
||||
when (type) {
|
||||
"tcp" -> {}
|
||||
"ws", "http" -> {
|
||||
if (host.isNotBlank()) {
|
||||
builder.addQueryParameter("host", host)
|
||||
}
|
||||
if (path.isNotBlank()) {
|
||||
if (standard) {
|
||||
builder.addQueryParameter("path", path)
|
||||
} else {
|
||||
builder.encodedPath(path.pathSafe())
|
||||
}
|
||||
}
|
||||
if (type == "ws") {
|
||||
if (wsMaxEarlyData > 0) {
|
||||
builder.addQueryParameter("ed", "$wsMaxEarlyData")
|
||||
if (earlyDataHeaderName.isNotBlank()) {
|
||||
builder.addQueryParameter("eh", earlyDataHeaderName)
|
||||
}
|
||||
}
|
||||
} else if (type == "http" && !isTLS()) {
|
||||
return "" // no fmt?
|
||||
}
|
||||
}
|
||||
"grpc" -> {
|
||||
if (path.isNotBlank()) {
|
||||
builder.addQueryParameter("serviceName", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (security.isNotBlank() && security != "none") {
|
||||
builder.addQueryParameter("security", security)
|
||||
when (security) {
|
||||
"tls" -> {
|
||||
if (sni.isNotBlank()) {
|
||||
builder.addQueryParameter("sni", sni)
|
||||
}
|
||||
if (alpn.isNotBlank()) {
|
||||
builder.addQueryParameter("alpn", alpn)
|
||||
}
|
||||
if (certificates.isNotBlank()) {
|
||||
builder.addQueryParameter("cert", certificates)
|
||||
}
|
||||
if (allowInsecure) builder.addQueryParameter("allowInsecure", "1")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (packetEncoding) {
|
||||
1 -> {
|
||||
builder.addQueryParameter("packetEncoding", "packet")
|
||||
}
|
||||
2 -> {
|
||||
builder.addQueryParameter("packetEncoding", "xudp")
|
||||
}
|
||||
}
|
||||
|
||||
if (name.isNotBlank()) {
|
||||
builder.encodedFragment(name.urlSafe())
|
||||
}
|
||||
|
||||
// TODO vless flow: bean.encryption != "auto"
|
||||
|
||||
return builder.toLink(if (isVLESS) "vless" else "vmess")
|
||||
|
||||
}
|
||||
|
||||
fun buildSingBoxOutboundStreamSettings(bean: StandardV2RayBean): V2RayTransportOptions? {
|
||||
when (bean.type) {
|
||||
"tcp" -> {
|
||||
return null
|
||||
}
|
||||
"ws" -> {
|
||||
return V2RayTransportOptions_WebsocketOptions().apply {
|
||||
type = "ws"
|
||||
headers = mutableMapOf()
|
||||
|
||||
if (bean.host.isNotBlank()) {
|
||||
headers["Host"] = bean.host
|
||||
}
|
||||
|
||||
if (bean.path.contains("?ed=")) {
|
||||
path = bean.path.substringBefore("?ed=")
|
||||
max_early_data = bean.path.substringAfter("?ed=").toIntOrNull() ?: 2048
|
||||
early_data_header_name = "Sec-WebSocket-Protocol"
|
||||
} else {
|
||||
path = bean.path.takeIf { it.isNotBlank() } ?: "/"
|
||||
}
|
||||
|
||||
if (bean.wsMaxEarlyData > 0) {
|
||||
max_early_data = bean.wsMaxEarlyData
|
||||
}
|
||||
|
||||
if (bean.earlyDataHeaderName.isNotBlank()) {
|
||||
early_data_header_name = bean.earlyDataHeaderName
|
||||
}
|
||||
}
|
||||
}
|
||||
"http" -> {
|
||||
return V2RayTransportOptions_HTTPOptions().apply {
|
||||
type = "http"
|
||||
if (bean.host.isNotBlank()) {
|
||||
host = bean.host.split(",")
|
||||
}
|
||||
path = bean.path.takeIf { it.isNotBlank() } ?: "/"
|
||||
}
|
||||
}
|
||||
"quic" -> {
|
||||
return V2RayTransportOptions().apply {
|
||||
type = "quic"
|
||||
}
|
||||
}
|
||||
"grpc" -> {
|
||||
return V2RayTransportOptions_GRPCOptions().apply {
|
||||
type = "grpc"
|
||||
service_name = bean.path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// if (needKeepAliveInterval) {
|
||||
// sockopt = StreamSettingsObject.SockoptObject().apply {
|
||||
// tcpKeepAliveInterval = keepAliveInterval
|
||||
// }
|
||||
// }
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
fun buildSingBoxOutboundTLS(bean: StandardV2RayBean): OutboundTLSOptions? {
|
||||
if (bean.security != "tls") return null
|
||||
return OutboundTLSOptions().apply {
|
||||
enabled = true
|
||||
insecure = bean.allowInsecure
|
||||
if (bean.sni.isNotBlank()) server_name = bean.sni
|
||||
if (bean.alpn.isNotBlank()) alpn = bean.alpn.split("\n")
|
||||
if (bean.certificates.isNotBlank()) certificate = bean.certificates
|
||||
if (bean.utlsFingerprint.isNotBlank()) {
|
||||
utls = OutboundUTLSOptions().apply {
|
||||
enabled = true
|
||||
fingerprint = bean.utlsFingerprint
|
||||
}
|
||||
}
|
||||
if (bean.realityPubKey.isNotBlank() && bean.realityShortId.isNotBlank()) {
|
||||
reality = OutboundRealityOptions().apply {
|
||||
enabled = true
|
||||
public_key = bean.realityPubKey
|
||||
short_id = bean.realityShortId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun buildSingBoxOutboundStandardV2RayBean(bean: StandardV2RayBean): Outbound {
|
||||
when (bean) {
|
||||
is HttpBean -> {
|
||||
return Outbound_HTTPOptions().apply {
|
||||
type = "http"
|
||||
server = bean.serverAddress
|
||||
server_port = bean.serverPort
|
||||
username = bean.username
|
||||
password = bean.password
|
||||
tls = buildSingBoxOutboundTLS(bean)
|
||||
}
|
||||
}
|
||||
is VMessBean -> {
|
||||
if (bean.isVLESS) return Outbound_VLESSOptions().apply {
|
||||
type = "vless"
|
||||
server = bean.serverAddress
|
||||
server_port = bean.serverPort
|
||||
uuid = bean.uuid
|
||||
if (bean.encryption.isNotBlank() && bean.encryption != "auto") {
|
||||
flow = bean.encryption
|
||||
}
|
||||
when (bean.packetEncoding) {
|
||||
0 -> packet_encoding = ""
|
||||
1 -> packet_encoding = "packetaddr"
|
||||
2 -> packet_encoding = "xudp"
|
||||
}
|
||||
tls = buildSingBoxOutboundTLS(bean)
|
||||
transport = buildSingBoxOutboundStreamSettings(bean)
|
||||
}
|
||||
return Outbound_VMessOptions().apply {
|
||||
type = "vmess"
|
||||
server = bean.serverAddress
|
||||
server_port = bean.serverPort
|
||||
uuid = bean.uuid
|
||||
alter_id = bean.alterId
|
||||
security = bean.encryption.takeIf { it.isNotBlank() } ?: "auto"
|
||||
when (bean.packetEncoding) {
|
||||
0 -> packet_encoding = ""
|
||||
1 -> packet_encoding = "packetaddr"
|
||||
2 -> packet_encoding = "xudp"
|
||||
}
|
||||
tls = buildSingBoxOutboundTLS(bean)
|
||||
transport = buildSingBoxOutboundStreamSettings(bean)
|
||||
}
|
||||
}
|
||||
is TrojanBean -> {
|
||||
return Outbound_TrojanOptions().apply {
|
||||
type = "trojan"
|
||||
server = bean.serverAddress
|
||||
server_port = bean.serverPort
|
||||
password = bean.password
|
||||
tls = buildSingBoxOutboundTLS(bean)
|
||||
transport = buildSingBoxOutboundStreamSettings(bean)
|
||||
}
|
||||
}
|
||||
else -> throw IllegalStateException("can't reach")
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
package io.nekohasekai.sagernet.fmt.v2ray;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import io.nekohasekai.sagernet.fmt.KryoConverters;
|
||||
import moe.matsuri.nb4a.utils.JavaUtil;
|
||||
|
||||
public class VMessBean extends StandardV2RayBean {
|
||||
|
||||
public Integer alterId; // alterID == -1 --> VLESS
|
||||
|
||||
@Override
|
||||
public void initializeDefaultValues() {
|
||||
super.initializeDefaultValues();
|
||||
|
||||
alterId = alterId != null ? alterId : 0;
|
||||
encryption = JavaUtil.isNotBlank(encryption) ? encryption : "auto";
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Override
|
||||
public VMessBean clone() {
|
||||
return KryoConverters.deserialize(new VMessBean(), KryoConverters.serialize(this));
|
||||
}
|
||||
|
||||
public static final Creator<VMessBean> CREATOR = new CREATOR<VMessBean>() {
|
||||
@NonNull
|
||||
@Override
|
||||
public VMessBean newInstance() {
|
||||
return new VMessBean();
|
||||
}
|
||||
|
||||
@Override
|
||||
public VMessBean[] newArray(int size) {
|
||||
return new VMessBean[size];
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,75 @@
|
||||
package io.nekohasekai.sagernet.fmt.wireguard;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.esotericsoftware.kryo.io.ByteBufferInput;
|
||||
import com.esotericsoftware.kryo.io.ByteBufferOutput;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import io.nekohasekai.sagernet.fmt.AbstractBean;
|
||||
import io.nekohasekai.sagernet.fmt.KryoConverters;
|
||||
|
||||
public class WireGuardBean extends AbstractBean {
|
||||
|
||||
public String localAddress;
|
||||
public String privateKey;
|
||||
public String peerPublicKey;
|
||||
public String peerPreSharedKey;
|
||||
public Integer mtu;
|
||||
public String reserved;
|
||||
|
||||
@Override
|
||||
public void initializeDefaultValues() {
|
||||
super.initializeDefaultValues();
|
||||
if (localAddress == null) localAddress = "";
|
||||
if (privateKey == null) privateKey = "";
|
||||
if (peerPublicKey == null) peerPublicKey = "";
|
||||
if (peerPreSharedKey == null) peerPreSharedKey = "";
|
||||
if (mtu == null) mtu = 1420;
|
||||
if (reserved == null) reserved = "";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void serialize(ByteBufferOutput output) {
|
||||
output.writeInt(2);
|
||||
super.serialize(output);
|
||||
output.writeString(localAddress);
|
||||
output.writeString(privateKey);
|
||||
output.writeString(peerPublicKey);
|
||||
output.writeString(peerPreSharedKey);
|
||||
output.writeInt(mtu);
|
||||
output.writeString(reserved);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deserialize(ByteBufferInput input) {
|
||||
int version = input.readInt();
|
||||
super.deserialize(input);
|
||||
localAddress = input.readString();
|
||||
privateKey = input.readString();
|
||||
peerPublicKey = input.readString();
|
||||
peerPreSharedKey = input.readString();
|
||||
mtu = input.readInt();
|
||||
reserved = input.readString();
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Override
|
||||
public WireGuardBean clone() {
|
||||
return KryoConverters.deserialize(new WireGuardBean(), KryoConverters.serialize(this));
|
||||
}
|
||||
|
||||
public static final Creator<WireGuardBean> CREATOR = new CREATOR<WireGuardBean>() {
|
||||
@NonNull
|
||||
@Override
|
||||
public WireGuardBean newInstance() {
|
||||
return new WireGuardBean();
|
||||
}
|
||||
|
||||
@Override
|
||||
public WireGuardBean[] newArray(int size) {
|
||||
return new WireGuardBean[size];
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
package io.nekohasekai.sagernet.fmt.wireguard
|
||||
|
||||
import moe.matsuri.nb4a.SingBoxOptions
|
||||
|
||||
fun buildSingBoxOutboundWireguardBean(bean: WireGuardBean): SingBoxOptions.Outbound_WireGuardOptions_Fix {
|
||||
return SingBoxOptions.Outbound_WireGuardOptions_Fix().apply {
|
||||
type = "wireguard"
|
||||
server = bean.serverAddress
|
||||
server_port = bean.serverPort
|
||||
local_address = bean.localAddress.split("\n")
|
||||
private_key = bean.privateKey
|
||||
peer_public_key = bean.peerPublicKey
|
||||
pre_shared_key = bean.peerPreSharedKey
|
||||
mtu = bean.mtu
|
||||
if (bean.reserved.isNotBlank()) reserved = bean.reserved
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,102 @@
|
||||
package io.nekohasekai.sagernet.group
|
||||
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import io.nekohasekai.sagernet.R
|
||||
import io.nekohasekai.sagernet.database.GroupManager
|
||||
import io.nekohasekai.sagernet.database.ProxyGroup
|
||||
import io.nekohasekai.sagernet.ktx.onMainDispatcher
|
||||
import io.nekohasekai.sagernet.ktx.runOnMainDispatcher
|
||||
import io.nekohasekai.sagernet.ui.ThemedActivity
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
class GroupInterfaceAdapter(val context: ThemedActivity) : GroupManager.Interface {
|
||||
|
||||
override suspend fun confirm(message: String): Boolean {
|
||||
return suspendCoroutine {
|
||||
runOnMainDispatcher {
|
||||
MaterialAlertDialogBuilder(context).setTitle(R.string.confirm)
|
||||
.setMessage(message)
|
||||
.setPositiveButton(R.string.yes) { _, _ -> it.resume(true) }
|
||||
.setNegativeButton(R.string.no) { _, _ -> it.resume(false) }
|
||||
.setOnCancelListener { _ -> it.resume(false) }
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun onUpdateSuccess(
|
||||
group: ProxyGroup,
|
||||
changed: Int,
|
||||
added: List<String>,
|
||||
updated: Map<String, String>,
|
||||
deleted: List<String>,
|
||||
duplicate: List<String>,
|
||||
byUser: Boolean
|
||||
) {
|
||||
if (changed == 0 && duplicate.isEmpty()) {
|
||||
if (byUser) context.snackbar(
|
||||
context.getString(
|
||||
R.string.group_no_difference, group.displayName()
|
||||
)
|
||||
).show()
|
||||
} else {
|
||||
context.snackbar(context.getString(R.string.group_updated, group.name, changed)).show()
|
||||
|
||||
var status = ""
|
||||
if (added.isNotEmpty()) {
|
||||
status += context.getString(
|
||||
R.string.group_added, added.joinToString("\n", postfix = "\n\n")
|
||||
)
|
||||
}
|
||||
if (updated.isNotEmpty()) {
|
||||
status += context.getString(R.string.group_changed,
|
||||
updated.map { it }.joinToString("\n", postfix = "\n\n") {
|
||||
if (it.key == it.value) it.key else "${it.key} => ${it.value}"
|
||||
})
|
||||
}
|
||||
if (deleted.isNotEmpty()) {
|
||||
status += context.getString(
|
||||
R.string.group_deleted, deleted.joinToString("\n", postfix = "\n\n")
|
||||
)
|
||||
}
|
||||
if (duplicate.isNotEmpty()) {
|
||||
status += context.getString(
|
||||
R.string.group_duplicate, duplicate.joinToString("\n", postfix = "\n\n")
|
||||
)
|
||||
}
|
||||
|
||||
onMainDispatcher {
|
||||
delay(1000L)
|
||||
|
||||
MaterialAlertDialogBuilder(context).setTitle(
|
||||
context.getString(
|
||||
R.string.group_diff, group.displayName()
|
||||
)
|
||||
).setMessage(status.trim()).setPositiveButton(android.R.string.ok, null).show()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override suspend fun onUpdateFailure(group: ProxyGroup, message: String) {
|
||||
onMainDispatcher {
|
||||
context.snackbar(message).show()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun alert(message: String) {
|
||||
return suspendCoroutine {
|
||||
runOnMainDispatcher {
|
||||
MaterialAlertDialogBuilder(context).setTitle(R.string.ooc_warning)
|
||||
.setMessage(message)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ -> it.resume(Unit) }
|
||||
.setOnCancelListener { _ -> it.resume(Unit) }
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
171
app/src/main/java/io/nekohasekai/sagernet/group/GroupUpdater.kt
Normal file
171
app/src/main/java/io/nekohasekai/sagernet/group/GroupUpdater.kt
Normal file
@ -0,0 +1,171 @@
|
||||
package io.nekohasekai.sagernet.group
|
||||
|
||||
import io.nekohasekai.sagernet.*
|
||||
import io.nekohasekai.sagernet.database.DataStore
|
||||
import io.nekohasekai.sagernet.database.GroupManager
|
||||
import io.nekohasekai.sagernet.database.ProxyGroup
|
||||
import io.nekohasekai.sagernet.database.SubscriptionBean
|
||||
import io.nekohasekai.sagernet.fmt.AbstractBean
|
||||
import io.nekohasekai.sagernet.fmt.http.HttpBean
|
||||
import io.nekohasekai.sagernet.fmt.hysteria.HysteriaBean
|
||||
import io.nekohasekai.sagernet.fmt.naive.NaiveBean
|
||||
import io.nekohasekai.sagernet.fmt.trojan.TrojanBean
|
||||
import io.nekohasekai.sagernet.fmt.trojan_go.TrojanGoBean
|
||||
import io.nekohasekai.sagernet.fmt.v2ray.StandardV2RayBean
|
||||
import io.nekohasekai.sagernet.fmt.v2ray.isTLS
|
||||
import io.nekohasekai.sagernet.ktx.*
|
||||
import kotlinx.coroutines.*
|
||||
import java.net.Inet4Address
|
||||
import java.net.InetAddress
|
||||
import java.util.*
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
@Suppress("EXPERIMENTAL_API_USAGE")
|
||||
abstract class GroupUpdater {
|
||||
|
||||
abstract suspend fun doUpdate(
|
||||
proxyGroup: ProxyGroup,
|
||||
subscription: SubscriptionBean,
|
||||
userInterface: GroupManager.Interface?,
|
||||
byUser: Boolean
|
||||
)
|
||||
|
||||
data class Progress(
|
||||
var max: Int
|
||||
) {
|
||||
var progress by AtomicInteger()
|
||||
}
|
||||
|
||||
protected suspend fun forceResolve(
|
||||
profiles: List<AbstractBean>, groupId: Long?
|
||||
) {
|
||||
val ipv6Mode = DataStore.ipv6Mode
|
||||
val lookupPool = newFixedThreadPoolContext(5, "DNS Lookup")
|
||||
val lookupJobs = mutableListOf<Job>()
|
||||
val progress = Progress(profiles.size)
|
||||
if (groupId != null) {
|
||||
GroupUpdater.progress[groupId] = progress
|
||||
GroupManager.postReload(groupId)
|
||||
}
|
||||
val ipv6First = ipv6Mode >= IPv6Mode.PREFER
|
||||
|
||||
for (profile in profiles) {
|
||||
when (profile) {
|
||||
// SNI rewrite unsupported
|
||||
is NaiveBean -> continue
|
||||
}
|
||||
|
||||
if (profile.serverAddress.isIpAddress()) continue
|
||||
|
||||
lookupJobs.add(GlobalScope.launch(lookupPool) {
|
||||
try {
|
||||
val results = if (
|
||||
SagerNet.underlyingNetwork != null &&
|
||||
DataStore.enableFakeDns &&
|
||||
DataStore.serviceState.started &&
|
||||
DataStore.serviceMode == Key.MODE_VPN
|
||||
) {
|
||||
// FakeDNS
|
||||
SagerNet.underlyingNetwork!!
|
||||
.getAllByName(profile.serverAddress)
|
||||
.filterNotNull()
|
||||
} else {
|
||||
// System DNS is enough (when VPN connected, it uses v2ray-core)
|
||||
InetAddress.getAllByName(profile.serverAddress).filterNotNull()
|
||||
}
|
||||
if (results.isEmpty()) error("empty response")
|
||||
rewriteAddress(profile, results, ipv6First)
|
||||
} catch (e: Exception) {
|
||||
Logs.d("Lookup ${profile.serverAddress} failed: ${e.readableMessage}", e)
|
||||
}
|
||||
if (groupId != null) {
|
||||
progress.progress++
|
||||
GroupManager.postReload(groupId)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
lookupJobs.joinAll()
|
||||
lookupPool.close()
|
||||
}
|
||||
|
||||
protected fun rewriteAddress(
|
||||
bean: AbstractBean, addresses: List<InetAddress>, ipv6First: Boolean
|
||||
) {
|
||||
val address = addresses.sortedBy { (it is Inet4Address) xor ipv6First }[0].hostAddress
|
||||
|
||||
with(bean) {
|
||||
when (this) {
|
||||
is HttpBean -> {
|
||||
if (isTLS() && sni.isBlank()) sni = bean.serverAddress
|
||||
}
|
||||
is StandardV2RayBean -> {
|
||||
when (security) {
|
||||
"tls" -> if (sni.isBlank()) sni = bean.serverAddress
|
||||
}
|
||||
}
|
||||
is TrojanBean -> {
|
||||
if (sni.isBlank()) sni = bean.serverAddress
|
||||
}
|
||||
is TrojanGoBean -> {
|
||||
if (sni.isBlank()) sni = bean.serverAddress
|
||||
}
|
||||
is HysteriaBean -> {
|
||||
if (sni.isBlank()) sni = bean.serverAddress
|
||||
}
|
||||
}
|
||||
|
||||
bean.serverAddress = address
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
val updating = Collections.synchronizedSet<Long>(mutableSetOf())
|
||||
val progress = Collections.synchronizedMap<Long, Progress>(mutableMapOf())
|
||||
|
||||
fun startUpdate(proxyGroup: ProxyGroup, byUser: Boolean) {
|
||||
runOnDefaultDispatcher {
|
||||
executeUpdate(proxyGroup, byUser)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun executeUpdate(proxyGroup: ProxyGroup, byUser: Boolean): Boolean {
|
||||
return coroutineScope {
|
||||
if (!updating.add(proxyGroup.id)) cancel()
|
||||
GroupManager.postReload(proxyGroup.id)
|
||||
|
||||
val subscription = proxyGroup.subscription!!
|
||||
val connected = DataStore.serviceState.connected
|
||||
val userInterface = GroupManager.userInterface
|
||||
|
||||
if (byUser && (subscription.link?.startsWith("http://") == true || subscription.updateWhenConnectedOnly) && !connected) {
|
||||
if (userInterface == null || !userInterface.confirm(app.getString(R.string.update_subscription_warning))) {
|
||||
finishUpdate(proxyGroup)
|
||||
cancel()
|
||||
return@coroutineScope true
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
RawUpdater.doUpdate(proxyGroup, subscription, userInterface, byUser)
|
||||
true
|
||||
} catch (e: Throwable) {
|
||||
Logs.w(e)
|
||||
userInterface?.onUpdateFailure(proxyGroup, e.readableMessage)
|
||||
finishUpdate(proxyGroup)
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
suspend fun finishUpdate(proxyGroup: ProxyGroup) {
|
||||
updating.remove(proxyGroup.id)
|
||||
progress.remove(proxyGroup.id)
|
||||
GroupManager.postUpdate(proxyGroup)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user