upload code

This commit is contained in:
arm64v8a 2023-03-15 00:00:00 +00:00
commit 7d9798cc27
513 changed files with 46925 additions and 0 deletions

View File

@ -0,0 +1,22 @@
---
name: Bug Report zh_CN
about: 问题反馈,在提出问题前请先自行排除服务器端问题和升级到最新客户端。
title: ''
labels: ''
assignees: ''
---
**描述问题**
预期行为:
实际行为:
**如何复现**
提供有帮助的截图,录像,文字说明,订阅链接等。
**日志**
如果有日志,请上传。请在文档内查看导出日志的详细步骤。

View File

@ -0,0 +1,12 @@
---
name: Feature Request zh_CN
about: 功能请求,提出建议。
title: ''
labels: ''
assignees: ''
---
**描述建议**
**建议的必要性**

171
.github/workflows/release.yml vendored Normal file
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,60 @@
# NekoBox for Android
[![API](https://img.shields.io/badge/API-21%2B-brightgreen.svg?style=flat)](https://android-arsenal.com/api?level=21)
[![Releases](https://img.shields.io/github/v/release/MatsuriDayo/NekoBoxForAndroid)](https://github.com/MatsuriDayo/NekoBoxForAndroid/releases)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-orange.svg)](https://www.gnu.org/licenses/gpl-3.0)
sing-box / universal proxy toolchain for Android.
## 下载 / Downloads
### GitHub Releases
[![GitHub All Releases](https://img.shields.io/github/downloads/Matsuridayo/NekoBoxForAndroid/total?label=downloads-total&logo=github&style=flat-square)](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
View File

@ -0,0 +1,2 @@
/build
/schemas

81
app/build.gradle.kts Normal file
View 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
View 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

View File

@ -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')"
]
}
}

View 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')"
]
}
}

View 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')"
]
}
}

View 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>

View File

@ -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();
}

View File

@ -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);
}

View File

@ -0,0 +1,3 @@
package io.nekohasekai.sagernet.aidl;
parcelable SpeedDisplayData;

View File

@ -0,0 +1,3 @@
package io.nekohasekai.sagernet.aidl;
parcelable TrafficData;

View 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/>.

View File

@ -0,0 +1,5 @@
domain:appcenter.ms
domain:app-measurement.com
domain:firebase.io
domain:crashlytics.com
domain:google-analytics.com

View File

@ -0,0 +1 @@
1

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

View 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

View File

@ -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)
}
}

View 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);
}
}

File diff suppressed because it is too large Load Diff

View 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;
}
}
}

View File

@ -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
}
}

View 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;
}
}

View 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"
}

View File

@ -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()
}
}

View 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
}
}

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,9 @@
package io.nekohasekai.sagernet.bg
import java.io.Closeable
interface AbstractInstance : Closeable {
fun launch()
}

View 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
}
}
}

View 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)
}
}
}
}
}

View File

@ -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() } }
}
}

View 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)
}

View 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
}
}

View File

@ -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)
}
}

View File

@ -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()
}
}
}

View 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()
}
}
}
}

View 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()
}
}

View File

@ -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()
}
}
}

View File

@ -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
}
}
}

View File

@ -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
}
}

View File

@ -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)
}
}
}
}
}

View File

@ -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
}
}
}
}

View File

@ -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()
}
}

View 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) {
}
}

View File

@ -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()
}
}

View File

@ -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);
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View 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)
}
}
}
}

View 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>)
}
}

View File

@ -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
}

View File

@ -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];
}
};
}

View File

@ -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)
}
}
}

View File

@ -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
}
}

View File

@ -0,0 +1,7 @@
package io.nekohasekai.sagernet.database.preference
import androidx.preference.PreferenceDataStore
interface OnPreferenceDataStoreChangeListener {
fun onPreferenceDataStoreChanged(store: PreferenceDataStore, key: String)
}

View File

@ -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
}

View File

@ -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)
}
}
}

View 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) {
}
}

View 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
)
}
}

View File

@ -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);
}
}

View 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
}
}
}

View File

@ -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())
}
}
}

View 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
}
}
}

View File

@ -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
}

View File

@ -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);
}
}

View File

@ -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];
}
};
}

View File

@ -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()
}

View File

@ -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];
}
};
}

View File

@ -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
}
}
}

View File

@ -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];
}
};
}

View File

@ -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;
}
}

View File

@ -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];
}
};
}

View File

@ -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()
}

View File

@ -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];
}
};
}

View File

@ -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(";")
}
}
}

View File

@ -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];
}
};
}

View File

@ -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()
}
}

View File

@ -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];
}
};
}

View 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
}
}
}
}

View File

@ -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];
}
};
}

View File

@ -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://")
}

View File

@ -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未来可能会有 h2h2+ws 等取值
* <p>
* 当取值为 original 使用原始 Trojan 传输方式无法方便通过 CDN
* 当取值为 ws 使用 wss 作为传输层
*/
public String type;
/**
* 自定义 HTTP Host
* 可以省略省略时值同 trojan-host
* 可以为空字符串但可能带来非预期情形
* <p>
* 警告若你的端口非标准端口不是 80 / 443RFC 标准规定 Host 应在主机名后附上端口号例如 example.com:44333至于是否遵守请自行斟酌
* <p>
* 必须使用 encodeURIComponent 编码
*/
public String host;
/**
* 当传输类型 type wsh2h2+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];
}
};
}

View File

@ -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", "")}"
}
}
}
}

View File

@ -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];
}
};
}

View File

@ -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()
}

View File

@ -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;
}
}

View 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")
}
}

View File

@ -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];
}
};
}

View File

@ -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];
}
};
}

View File

@ -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
}
}

View File

@ -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()
}
}
}
}

View 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