diff --git a/.gitignore b/.gitignore
new file mode 100644
index 000000000..712dcac06
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,16 @@
+*.iml
+.gradle
+/local.properties
+/.idea/caches
+/.idea/libraries
+/.idea/modules.xml
+/.idea/workspace.xml
+/.idea/navEditor.xml
+/.idea/assetWizardSettings.xml
+.DS_Store
+/build
+/captures
+.externalNativeBuild
+.cxx
+local.properties
+/app/libs/
\ No newline at end of file
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 000000000..b8e815aed
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,9 @@
+[submodule "app/src/main/jni/badvpn"]
+ path = app/src/main/jni/badvpn
+ url = https://github.com/shadowsocks/badvpn
+[submodule "app/src/main/jni/libancillary"]
+ path = app/src/main/jni/libancillary
+ url = https://github.com/shadowsocks/libancillary
+[submodule "v2ray"]
+ path = v2ray
+ url = https://github.com/nekohasekai/AndroidLibV2rayLite
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 000000000..26d33521a
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,3 @@
+# Default ignored files
+/shelf/
+/workspace.xml
diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
new file mode 100644
index 000000000..a3b7a9050
--- /dev/null
+++ b/.idea/codeStyles/Project.xml
@@ -0,0 +1,140 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ xmlns:android
+
+ ^$
+
+
+
+
+
+
+
+
+ xmlns:.*
+
+ ^$
+
+
+ BY_NAME
+
+
+
+
+
+
+ .*:id
+
+ http://schemas.android.com/apk/res/android
+
+
+
+
+
+
+
+
+ .*:name
+
+ http://schemas.android.com/apk/res/android
+
+
+
+
+
+
+
+
+ name
+
+ ^$
+
+
+
+
+
+
+
+
+ style
+
+ ^$
+
+
+
+
+
+
+
+
+ .*
+
+ ^$
+
+
+ BY_NAME
+
+
+
+
+
+
+ .*
+
+ http://schemas.android.com/apk/res/android
+
+
+ ANDROID_ATTRIBUTE_ORDER
+
+
+
+
+
+
+ .*
+
+ .*
+
+
+ BY_NAME
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml
new file mode 100644
index 000000000..79ee123c2
--- /dev/null
+++ b/.idea/codeStyles/codeStyleConfig.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/compiler.xml b/.idea/compiler.xml
new file mode 100644
index 000000000..61a9130cd
--- /dev/null
+++ b/.idea/compiler.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/dictionaries/sekai.xml b/.idea/dictionaries/sekai.xml
new file mode 100644
index 000000000..da24e543a
--- /dev/null
+++ b/.idea/dictionaries/sekai.xml
@@ -0,0 +1,11 @@
+
+
+
+ downlink
+ gson
+ snackbar
+ uplink
+ vmess
+
+
+
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
new file mode 100644
index 000000000..a3654909b
--- /dev/null
+++ b/.idea/gradle.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
new file mode 100644
index 000000000..7ef070a38
--- /dev/null
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml
new file mode 100644
index 000000000..237047402
--- /dev/null
+++ b/.idea/jarRepositories.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 000000000..fe0fc8717
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 000000000..572ec2b85
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/README b/README
new file mode 100644
index 000000000..f1c89c088
--- /dev/null
+++ b/README
@@ -0,0 +1,10 @@
+SagerNet
+============
+
+The universal proxy toolchain for Android, written in Kotlin.
+
+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/.
\ No newline at end of file
diff --git a/app/.gitignore b/app/.gitignore
new file mode 100644
index 000000000..42afabfd2
--- /dev/null
+++ b/app/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/app/build.gradle b/app/build.gradle
new file mode 100644
index 000000000..bb230f390
--- /dev/null
+++ b/app/build.gradle
@@ -0,0 +1,113 @@
+plugins {
+ id "com.android.application"
+ id "kotlin-android"
+ id "kotlin-kapt"
+ id "kotlin-parcelize"
+
+}
+
+android {
+ compileSdkVersion 30
+ buildToolsVersion "30.0.3"
+
+ defaultConfig {
+ applicationId "io.nekohasekai.sagernet"
+ minSdkVersion 21
+ targetSdkVersion 30
+ versionCode 1
+ versionName "0.1-SNAPSHOT"
+
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+
+ externalNativeBuild.ndkBuild {
+ abiFilters "armeabi-v7a", "arm64-v8a", "x86", "x86_64"
+ arguments "-j${Runtime.getRuntime().availableProcessors()}"
+ }
+
+ javaCompileOptions {
+ annotationProcessorOptions {
+ arguments += [
+ "room.schemaLocation":"$projectDir/schemas".toString(),
+ "room.incremental":"true",
+ "room.expandProjection":"true"]
+ }
+ }
+ }
+
+ externalNativeBuild.ndkBuild.path "src/main/jni/Android.mk"
+
+
+ buildTypes {
+ release {
+ minifyEnabled true
+ shrinkResources true
+ proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ coreLibraryDesugaringEnabled true
+ }
+
+ packagingOptions {
+ exclude "/META-INF/*.version"
+ exclude "/META-INF/*.kotlin_module"
+ exclude "/META-INF/native-image/**"
+ exclude "/META-INF/INDEX.LIST"
+ exclude "DebugProbesKt.bin"
+ exclude "/kotlin/**"
+ }
+
+ kotlinOptions {
+ jvmTarget = "1.8"
+ }
+}
+
+dependencies {
+
+ implementation fileTree(dir: "libs")
+ implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
+ implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.3"
+
+ implementation "androidx.core:core-ktx:1.6.0-alpha02"
+ implementation "androidx.activity:activity-ktx:1.3.0-alpha06"
+ implementation "androidx.fragment:fragment-ktx:1.3.2"
+
+ implementation "androidx.constraintlayout:constraintlayout:2.0.4"
+ implementation "androidx.navigation:navigation-fragment-ktx:2.3.5"
+ implementation "androidx.navigation:navigation-ui-ktx:2.3.5"
+ implementation "androidx.preference:preference-ktx:1.1.1"
+ implementation "androidx.appcompat:appcompat:1.2.0"
+
+ implementation "com.google.android.material:material:1.3.0"
+ implementation "com.github.daniel-stoneuk:material-about-library:3.2.0-rc01"
+ implementation "com.squareup.okhttp3:okhttp:5.0.0-alpha.2"
+ implementation "cn.hutool:hutool-core:5.6.3"
+ implementation "cn.hutool:hutool-json:5.6.3"
+ implementation "com.google.code.gson:gson:2.8.6"
+ implementation "me.weishu:free_reflection:3.0.1"
+ implementation "com.github.zawadz88.materialpopupmenu:material-popup-menu:4.1.0"
+ implementation "com.squareup.okhttp3:okhttp:5.0.0-alpha.2"
+ implementation 'androidx.preference:preference:1.1.1'
+
+ def room_version = "2.2.6"
+ implementation "androidx.room:room-runtime:$room_version"
+ kapt "androidx.room:room-compiler:$room_version"
+ implementation "androidx.room:room-ktx:$room_version"
+
+ def roomigrant_version = "0.3.4"
+ implementation "com.github.MatrixDev.Roomigrant:RoomigrantLib:$roomigrant_version"
+ kapt "com.github.MatrixDev.Roomigrant:RoomigrantCompiler:$roomigrant_version"
+
+ implementation "com.esotericsoftware:kryo:5.1.0"
+
+ testImplementation "junit:junit:4.13.2"
+ testImplementation "androidx.room:room-testing:$room_version"
+ androidTestImplementation "androidx.test.ext:junit:1.1.2"
+ androidTestImplementation "androidx.test.espresso:espresso-core:3.3.0"
+
+ coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:1.1.5"
+
+}
\ No newline at end of file
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 000000000..497d5452a
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -0,0 +1,23 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
+-dontobfuscate
+-keepattributes SourceFile,LineNumberTable
\ No newline at end of file
diff --git a/app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/1.json b/app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/1.json
new file mode 100644
index 000000000..902e5cee6
--- /dev/null
+++ b/app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/1.json
@@ -0,0 +1,197 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 1,
+ "identityHash": "e798091fbf9ed3facd2985f7560c4975",
+ "entities": [
+ {
+ "tableName": "proxy_groups",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userOrder` INTEGER NOT NULL, `isDefault` INTEGER NOT NULL, `name` TEXT, `isSubscription` INTEGER NOT NULL, `subscriptionLinks` TEXT NOT NULL, `lastUpdate` INTEGER NOT NULL, `layout` INTEGER NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userOrder",
+ "columnName": "userOrder",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isDefault",
+ "columnName": "isDefault",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "isSubscription",
+ "columnName": "isSubscription",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "subscriptionLinks",
+ "columnName": "subscriptionLinks",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastUpdate",
+ "columnName": "lastUpdate",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "layout",
+ "columnName": "layout",
+ "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` TEXT NOT NULL, `userOrder` INTEGER NOT NULL, `tx` INTEGER NOT NULL, `rx` INTEGER NOT NULL, `proxyApps` INTEGER NOT NULL, `individual` TEXT, `meteredNetwork` INTEGER NOT NULL, `vmessBean` BLOB, `socksBean` BLOB)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "groupId",
+ "columnName": "groupId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "type",
+ "columnName": "type",
+ "affinity": "TEXT",
+ "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": "proxyApps",
+ "columnName": "proxyApps",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "individual",
+ "columnName": "individual",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "meteredNetwork",
+ "columnName": "meteredNetwork",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "vmessBean",
+ "columnName": "vmessBean",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "socksBean",
+ "columnName": "socksBean",
+ "affinity": "BLOB",
+ "notNull": false
+ }
+ ],
+ "primaryKey": {
+ "columnNames": [
+ "id"
+ ],
+ "autoGenerate": true
+ },
+ "indices": [
+ {
+ "name": "groupId",
+ "unique": false,
+ "columnNames": [
+ "groupId"
+ ],
+ "createSql": "CREATE INDEX IF NOT EXISTS `groupId` ON `${TABLE_NAME}` (`groupId`)"
+ }
+ ],
+ "foreignKeys": []
+ },
+ {
+ "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, 'e798091fbf9ed3facd2985f7560c4975')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/schemas/io.nekohasekai.sagernet.database.preference.PublicDatabase/1.json b/app/schemas/io.nekohasekai.sagernet.database.preference.PublicDatabase/1.json
new file mode 100644
index 000000000..4986920a2
--- /dev/null
+++ b/app/schemas/io.nekohasekai.sagernet.database.preference.PublicDatabase/1.json
@@ -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')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/src/androidTest/java/io/nekohasekai/sagernet/ExampleInstrumentedTest.kt b/app/src/androidTest/java/io/nekohasekai/sagernet/ExampleInstrumentedTest.kt
new file mode 100644
index 000000000..38d598995
--- /dev/null
+++ b/app/src/androidTest/java/io/nekohasekai/sagernet/ExampleInstrumentedTest.kt
@@ -0,0 +1,24 @@
+package io.nekohasekai.sagernet
+
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.ext.junit.runners.AndroidJUnit4
+
+import org.junit.Test
+import org.junit.runner.RunWith
+
+import org.junit.Assert.*
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+@RunWith(AndroidJUnit4::class)
+class ExampleInstrumentedTest {
+ @Test
+ fun useAppContext() {
+ // Context of the app under test.
+ val appContext = InstrumentationRegistry.getInstrumentation().targetContext
+ assertEquals("io.nekohasekai.sagernet", appContext.packageName)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..26355025c
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/aidl/com/github/shadowsocks/aidl/IShadowsocksService.aidl b/app/src/main/aidl/com/github/shadowsocks/aidl/IShadowsocksService.aidl
new file mode 100644
index 000000000..026455be5
--- /dev/null
+++ b/app/src/main/aidl/com/github/shadowsocks/aidl/IShadowsocksService.aidl
@@ -0,0 +1,13 @@
+package com.github.shadowsocks.aidl;
+
+import com.github.shadowsocks.aidl.IShadowsocksServiceCallback;
+
+interface IShadowsocksService {
+ int getState();
+ String getProfileName();
+
+ void registerCallback(in IShadowsocksServiceCallback cb);
+ void startListeningForBandwidth(in IShadowsocksServiceCallback cb, long timeout);
+ oneway void stopListeningForBandwidth(in IShadowsocksServiceCallback cb);
+ oneway void unregisterCallback(in IShadowsocksServiceCallback cb);
+}
diff --git a/app/src/main/aidl/com/github/shadowsocks/aidl/IShadowsocksServiceCallback.aidl b/app/src/main/aidl/com/github/shadowsocks/aidl/IShadowsocksServiceCallback.aidl
new file mode 100644
index 000000000..b116db6d6
--- /dev/null
+++ b/app/src/main/aidl/com/github/shadowsocks/aidl/IShadowsocksServiceCallback.aidl
@@ -0,0 +1,10 @@
+package com.github.shadowsocks.aidl;
+
+import com.github.shadowsocks.aidl.TrafficStats;
+
+oneway interface IShadowsocksServiceCallback {
+ void stateChanged(int state, String profileName, String msg);
+ void trafficUpdated(long profileId, in TrafficStats stats);
+ // Traffic data has persisted to database, listener should refetch their data from database
+ void trafficPersisted(long profileId);
+}
diff --git a/app/src/main/aidl/com/github/shadowsocks/aidl/TrafficStats.aidl b/app/src/main/aidl/com/github/shadowsocks/aidl/TrafficStats.aidl
new file mode 100644
index 000000000..8668fa849
--- /dev/null
+++ b/app/src/main/aidl/com/github/shadowsocks/aidl/TrafficStats.aidl
@@ -0,0 +1,3 @@
+package com.github.shadowsocks.aidl;
+
+parcelable TrafficStats;
diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png
new file mode 100644
index 000000000..cb34b42f9
Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ
diff --git a/app/src/main/java/com/github/shadowsocks/aidl/TrafficStats.kt b/app/src/main/java/com/github/shadowsocks/aidl/TrafficStats.kt
new file mode 100644
index 000000000..31229ee4f
--- /dev/null
+++ b/app/src/main/java/com/github/shadowsocks/aidl/TrafficStats.kt
@@ -0,0 +1,21 @@
+
+
+package com.github.shadowsocks.aidl
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+
+@Parcelize
+data class TrafficStats(
+ // Bytes per second
+ var txRate: Long = 0L,
+ var rxRate: Long = 0L,
+
+ // Bytes for the current session
+ var txTotal: Long = 0L,
+ var rxTotal: Long = 0L,
+) : Parcelable {
+ operator fun plus(other: TrafficStats) = TrafficStats(
+ txRate + other.txRate, rxRate + other.rxRate,
+ txTotal + other.txTotal, rxTotal + other.rxTotal)
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/Constants.kt b/app/src/main/java/io/nekohasekai/sagernet/Constants.kt
new file mode 100644
index 000000000..ae46cb202
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/Constants.kt
@@ -0,0 +1,23 @@
+package io.nekohasekai.sagernet
+
+object Key {
+
+ const val DB_PUBLIC = "configuration.db"
+ const val DB_PROFILE = "sager_net.db"
+ const val DISABLE_AEAD = "V2RAY_VMESS_AEAD_DISABLED"
+
+ const val SERVICE_MODE = "service_mode"
+ const val MODE_VPN = 0
+ const val MODE_PROXY = 1
+ const val MODE_TRANS = 2
+
+}
+
+object Action {
+ const val SERVICE = "com.github.shadowsocks.SERVICE"
+ const val CLOSE = "com.github.shadowsocks.CLOSE"
+ const val RELOAD = "com.github.shadowsocks.RELOAD"
+ const val ABORT = "com.github.shadowsocks.ABORT"
+
+ const val EXTRA_PROFILE_ID = "com.github.shadowsocks.EXTRA_PROFILE_ID"
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/SagerApp.kt b/app/src/main/java/io/nekohasekai/sagernet/SagerApp.kt
new file mode 100644
index 000000000..e3764d176
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/SagerApp.kt
@@ -0,0 +1,103 @@
+package io.nekohasekai.sagernet
+
+import android.app.*
+import android.app.admin.DevicePolicyManager
+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.os.Build
+import android.os.UserManager
+import androidx.annotation.RequiresApi
+import androidx.core.content.ContextCompat
+import androidx.core.content.getSystemService
+import io.nekohasekai.sagernet.bg.SagerConnection
+import io.nekohasekai.sagernet.database.DataStore
+import io.nekohasekai.sagernet.ui.MainActivity
+import io.nekohasekai.sagernet.utils.DeviceStorageApp
+import me.weishu.reflection.Reflection
+
+class SagerApp : Application() {
+
+ companion object {
+ lateinit var application: SagerApp
+ 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), 0)
+ }
+ }
+ val activity by lazy { application.getSystemService()!! }
+ val clipboard by lazy { application.getSystemService()!! }
+ val connectivity by lazy { application.getSystemService()!! }
+ val notification by lazy { application.getSystemService()!! }
+ val user by lazy { application.getSystemService()!! }
+ val packageInfo: PackageInfo by lazy { application.getPackageInfo(application.packageName) }
+ val directBootSupported by lazy {
+ Build.VERSION.SDK_INT >= 24 && application.getSystemService()?.storageEncryptionStatus ==
+ DevicePolicyManager.ENCRYPTION_STATUS_ACTIVE_PER_USER
+ }
+
+ 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-transproxy",
+ application.getText(R.string.service_transproxy),
+ NotificationManager.IMPORTANCE_LOW)))
+ }
+ }
+
+ 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))
+
+ }
+
+ override fun attachBaseContext(base: Context) {
+ super.attachBaseContext(base)
+ application = this
+ Reflection.unseal(base)
+ }
+
+ override fun onCreate() {
+ super.onCreate()
+ DataStore.init()
+ updateNotificationChannels()
+ }
+
+ fun getPackageInfo(packageName: String) = packageManager.getPackageInfo(packageName,
+ if (Build.VERSION.SDK_INT >= 28) PackageManager.GET_SIGNING_CERTIFICATES
+ else @Suppress("DEPRECATION") PackageManager.GET_SIGNATURES)!!
+
+ fun trySetPrimaryClip(clip: String) = try {
+ clipboard.setPrimaryClip(ClipData.newPlainText(null, clip))
+ true
+ } catch (e: RuntimeException) {
+ false
+ }
+
+ override fun onConfigurationChanged(newConfig: Configuration) {
+ super.onConfigurationChanged(newConfig)
+ updateNotificationChannels()
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt
new file mode 100644
index 000000000..9eb8d4601
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt
@@ -0,0 +1,308 @@
+package io.nekohasekai.sagernet.bg
+
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.os.Build
+import android.os.IBinder
+import android.os.RemoteCallbackList
+import android.os.RemoteException
+import com.github.shadowsocks.aidl.IShadowsocksService
+import com.github.shadowsocks.aidl.IShadowsocksServiceCallback
+import com.github.shadowsocks.aidl.TrafficStats
+import io.nekohasekai.sagernet.Action
+import io.nekohasekai.sagernet.R
+import io.nekohasekai.sagernet.database.DataStore
+import io.nekohasekai.sagernet.database.SagerDatabase
+import io.nekohasekai.sagernet.ktx.broadcastReceiver
+import io.nekohasekai.sagernet.ktx.readableMessage
+import kotlinx.coroutines.*
+import java.net.URL
+import java.net.UnknownHostException
+
+class BaseService {
+
+ enum class State(val canStop: Boolean = false) {
+ /**
+ * Idle state is only used by UI and will never be returned by BaseService.
+ */
+ Idle,
+ Connecting(true),
+ Connected(true),
+ Stopping,
+ Stopped,
+ }
+
+ interface ExpectedException
+ class ExpectedExceptionWrapper(e: Exception) : Exception(e.localizedMessage, e),
+ ExpectedException
+
+ class Data internal constructor(private val service: Interface) {
+ var state = State.Stopped
+ var processes: GuardedProcessPool? = null
+ var proxy: ProxyInstance? = null
+ var notification: ServiceNotification? = null
+
+ val closeReceiver = broadcastReceiver { _, intent ->
+ when (intent.action) {
+ Intent.ACTION_SHUTDOWN -> service.persistStats()
+ Action.RELOAD -> service.forceLoad()
+ 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) : IShadowsocksService.Stub(), CoroutineScope,
+ AutoCloseable {
+ private val callbacks = object : RemoteCallbackList() {
+ override fun onCallbackDied(callback: IShadowsocksServiceCallback?, cookie: Any?) {
+ super.onCallbackDied(callback, cookie)
+ stopListeningForBandwidth(callback ?: return)
+ }
+ }
+ private val bandwidthListeners =
+ mutableMapOf() // the binder is the real identifier
+ override val coroutineContext = Dispatchers.Main.immediate + Job()
+ private var looper: Job? = null
+
+ override fun getState(): Int = (data?.state ?: State.Idle).ordinal
+ override fun getProfileName(): String = data?.proxy?.profile?.requireBean()?.name ?: "Idle"
+
+ override fun registerCallback(cb: IShadowsocksServiceCallback) {
+ callbacks.register(cb)
+ }
+
+ private fun broadcast(work: (IShadowsocksServiceCallback) -> Unit) {
+ val count = callbacks.beginBroadcast()
+ try {
+ repeat(count) {
+ try {
+ work(callbacks.getBroadcastItem(it))
+ } catch (_: RemoteException) {
+ } catch (e: Exception) {
+ }
+ }
+ } finally {
+ callbacks.finishBroadcast()
+ }
+ }
+
+ private suspend fun loop() {
+ var lastQueryTime = 0L
+ while (true) {
+ delay(bandwidthListeners.values.minOrNull() ?: return)
+ val queryTime = System.currentTimeMillis()
+ val sinceLastQueryInSeconds = (queryTime - lastQueryTime) / 1000.0
+ val proxy = data?.proxy ?: continue
+ lastQueryTime = queryTime
+ val up = proxy.uplink
+ val down = proxy.downlink
+ if (up + down == 0L) continue
+ val stats = TrafficStats(up / sinceLastQueryInSeconds.toLong(),
+ down / sinceLastQueryInSeconds.toLong(),
+ up, down)
+ if (data?.state == State.Connected && bandwidthListeners.isNotEmpty()) {
+ broadcast { item ->
+ if (bandwidthListeners.contains(item.asBinder())) {
+ item.trafficUpdated(proxy.profile.id, stats)
+ }
+ }
+ }
+
+ }
+
+ }
+
+ override fun startListeningForBandwidth(
+ cb: IShadowsocksServiceCallback,
+ timeout: Long,
+ ) {
+ launch {
+ if (bandwidthListeners.isEmpty() and (bandwidthListeners.put(cb.asBinder(),
+ timeout) == null)
+ ) {
+ check(looper == null)
+ looper = launch { loop() }
+ }
+ if (data?.state != State.Connected) return@launch
+ val data = data
+ data?.proxy ?: return@launch
+ val sum = TrafficStats()
+ cb.trafficUpdated(0, sum)
+ }
+ }
+
+ override fun stopListeningForBandwidth(cb: IShadowsocksServiceCallback) {
+ launch {
+ if (bandwidthListeners.remove(cb.asBinder()) != null && bandwidthListeners.isEmpty()) {
+ looper!!.cancel()
+ looper = null
+ }
+ }
+ }
+
+ override fun unregisterCallback(cb: IShadowsocksServiceCallback) {
+ stopListeningForBandwidth(cb) // saves an RPC, and safer
+ callbacks.unregister(cb)
+ }
+
+ fun stateChanged(s: State, msg: String?) = launch {
+ val profileName = profileName
+ broadcast { it.stateChanged(s.ordinal, profileName, msg) }
+ }
+
+ fun trafficPersisted(ids: List) = launch {
+ if (bandwidthListeners.isNotEmpty() && ids.isNotEmpty()) broadcast { item ->
+ if (bandwidthListeners.contains(item.asBinder())) ids.forEach(item::trafficPersisted)
+ }
+ }
+
+ 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() {
+ stopRunner(false, (this as Context).getString(R.string.profile_empty))
+ val s = data.state
+ when {
+ s == State.Stopped -> startRunner()
+ s.canStop -> stopRunner(true)
+ //else -> Timber.w("Illegal state $s when invoking use")
+ }
+ }
+
+ val isVpnService get() = false
+
+ suspend fun startProcesses() {
+ GlobalScope.launch(Dispatchers.IO) { data.proxy!!.start() }
+ }
+
+ fun startRunner() {
+ this as Context
+ if (Build.VERSION.SDK_INT >= 26) startForegroundService(Intent(this, javaClass))
+ else startService(Intent(this, javaClass))
+ }
+
+ fun killProcesses(scope: CoroutineScope) {
+ data.proxy?.stop()
+ data.processes?.run {
+ close(scope)
+ data.processes = null
+ }
+ }
+
+ fun stopRunner(restart: Boolean = false, msg: String? = null) {
+ if (data.state == State.Stopping) return
+ // channge the state
+ data.changeState(State.Stopping)
+ GlobalScope.launch(Dispatchers.Main.immediate) {
+ data.connectingJob?.cancelAndJoin() // ensure stop connecting first
+ this@Interface as Service
+ // we use a coroutineScope here to allow clean-up in parallel
+ coroutineScope {
+ killProcesses(this)
+ // clean up receivers
+ val data = data
+ if (data.closeReceiverRegistered) {
+ unregisterReceiver(data.closeReceiver)
+ data.closeReceiverRegistered = false
+ }
+
+ data.notification?.destroy()
+ data.notification = null
+ data.binder.trafficPersisted(listOfNotNull(data.proxy).map { it.profile.id })
+ data.proxy = null
+ }
+
+ // change the state
+ data.changeState(State.Stopped, msg)
+
+ // stop the service if nothing has bound to it
+ if (restart) startRunner() else {
+ // BootReceiver.enabled = false
+ stopSelf()
+ }
+ }
+ }
+
+ fun persistStats() =
+ listOfNotNull(data.proxy).forEach { it.persistStats() }
+
+ suspend fun preInit() {}
+ suspend fun openConnection(url: URL) = url.openConnection()
+
+ fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ 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)
+ data.proxy = proxy
+ if (!data.closeReceiverRegistered) {
+ registerReceiver(data.closeReceiver, IntentFilter().apply {
+ addAction(Action.RELOAD)
+ addAction(Intent.ACTION_SHUTDOWN)
+ addAction(Action.CLOSE)
+ }, "$packageName.SERVICE", null)
+ data.closeReceiverRegistered = true
+ }
+
+ data.notification = createNotification(profile.requireBean().name)
+
+ data.changeState(State.Connecting)
+ data.connectingJob = GlobalScope.launch(Dispatchers.Main) {
+ try {
+ Executable.killAll() // clean up old processes
+ preInit()
+ proxy.init(this@Interface)
+ data.processes = GuardedProcessPool {
+// Timber.w(it)
+ stopRunner(false, it.readableMessage)
+ }
+ startProcesses()
+ data.changeState(State.Connected)
+ } 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 (exc: Throwable) {
+// if (exc is ExpectedException) Timber.d(exc) else Timber.w(exc)
+ stopRunner(false,
+ "${getString(R.string.service_failed)}: ${exc.readableMessage}")
+ } finally {
+ data.connectingJob = null
+ }
+ }
+ return Service.START_NOT_STICKY
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/Executable.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/Executable.kt
new file mode 100644
index 000000000..bd3b69cab
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/bg/Executable.kt
@@ -0,0 +1,35 @@
+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 {
+ const val SS_LOCAL = "libsslocal.so"
+ const val TUN2SOCKS = "libtun2socks.so"
+
+ private val EXECUTABLES = setOf(SS_LOCAL, TUN2SOCKS)
+
+ fun killAll() {
+ for (process in File("/proc").listFiles { _, name -> TextUtils.isDigitsOnly(name) }
+ ?: return) {
+ val exe = File(try {
+ File(process, "cmdline").inputStream().bufferedReader().readText()
+ } catch (_: IOException) {
+ continue
+ }.split(Character.MIN_VALUE, limit = 2).first())
+ if (EXECUTABLES.contains(exe.name)) try {
+ Os.kill(process.name.toInt(), OsConstants.SIGKILL)
+ } catch (e: ErrnoException) {
+ if (e.errno != OsConstants.ESRCH) {
+ Logs.w("SIGKILL ${exe.absolutePath} (${process.name}) failed")
+ Logs.w(e)
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/GuardedProcessPool.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/GuardedProcessPool.kt
new file mode 100644
index 000000000..bd028f8e3
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/bg/GuardedProcessPool.kt
@@ -0,0 +1,110 @@
+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 android.util.Log
+import androidx.annotation.MainThread
+import io.nekohasekai.sagernet.SagerApp
+import io.nekohasekai.sagernet.ktx.Logs
+import io.nekohasekai.sagernet.utils.Commandline
+import kotlinx.coroutines.*
+import kotlinx.coroutines.channels.Channel
+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) {
+ 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(SagerApp.deviceStorage.noBackupFilesDir).start()
+ }
+
+ suspend fun looper(onRestartCallback: (suspend () -> Unit)?) {
+ var running = true
+ val cmdName = File(cmd.first()).nameWithoutExtension
+ val exitChannel = Channel()
+ try {
+ while (true) {
+ thread(name = "stderr-$cmdName") {
+ streamLogger(process.errorStream) { Log.e(cmdName, it) }
+ }
+ thread(name = "stdout-$cmdName") {
+ streamLogger(process.inputStream) { Log.v(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, onRestartCallback: (suspend () -> Unit)? = null) {
+ Logs.i("start process: ${Commandline.toString(cmd)}")
+ Guard(cmd).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.join() } }
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/ProxyInstance.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/ProxyInstance.kt
new file mode 100644
index 000000000..3470c3aaf
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/bg/ProxyInstance.kt
@@ -0,0 +1,83 @@
+package io.nekohasekai.sagernet.bg
+
+import io.nekohasekai.sagernet.database.DataStore
+import io.nekohasekai.sagernet.database.ProxyEntity
+import io.nekohasekai.sagernet.database.SagerDatabase
+import io.nekohasekai.sagernet.fmt.socks.SOCKSBean
+import io.nekohasekai.sagernet.fmt.v2ray.buildV2rayConfig
+import io.nekohasekai.sagernet.ktx.Logs
+import libv2ray.Libv2ray
+import libv2ray.V2RayPoint
+import libv2ray.V2RayVPNServiceSupportsSet
+import java.io.IOException
+
+class ProxyInstance(val profile: ProxyEntity) {
+
+ lateinit var v2rayPoint: V2RayPoint
+ lateinit var service: VpnService
+
+ fun init(service: BaseService.Interface) {
+ v2rayPoint = Libv2ray.newV2RayPoint(SagerSupportClass(if (service is VpnService)
+ service else null), false)
+ if (profile.requireBean() is SOCKSBean) {
+ val socks = profile.requireSOCKS()
+ v2rayPoint.domainName = socks.serverAddress + ":" + socks.serverPort
+ v2rayPoint.configureFileContent = buildV2rayConfig(socks,
+ if (DataStore.allowAccess) "0.0.0.0" else "127.0.0.1",
+ DataStore.socks5Port
+ )
+ }
+ }
+
+ fun start() {
+ v2rayPoint.runLoop(DataStore.preferIpv6)
+ println("Satrted")
+ }
+
+ fun stop() {
+ v2rayPoint.stopLoop()
+ }
+
+ val uplink
+ get() = if (!::v2rayPoint.isInitialized) -1L else v2rayPoint.queryStats("out",
+ "uplink")
+ val downlink
+ get() = if (!::v2rayPoint.isInitialized) -1L else v2rayPoint.queryStats("out",
+ "downlink")
+
+ fun persistStats() {
+ try {
+ profile.tx += uplink
+ profile.rx += downlink
+ SagerDatabase.proxyDao.updateProxy(profile)
+ } catch (e: IOException) {
+ /* if (!DataStore.directBootAware) throw e*/ // we should only reach here because we're in direct boot
+ }
+ }
+
+ private class SagerSupportClass(val service: VpnService?) : V2RayVPNServiceSupportsSet {
+
+ override fun onEmitStatus(p0: Long, status: String): Long {
+ Logs.i("onEmitStatus $status")
+ return 0L
+ }
+
+ override fun prepare(): Long {
+ return 0L
+ }
+
+ override fun protect(l: Long): Boolean {
+ return (service ?: return true).protect(l.toInt())
+ }
+
+ override fun setup(p0: String?): Long {
+ return 0
+ }
+
+ override fun shutdown(): Long {
+ return 0
+ }
+ }
+
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/ProxyService.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/ProxyService.kt
new file mode 100644
index 000000000..4906bef90
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/bg/ProxyService.kt
@@ -0,0 +1,20 @@
+package io.nekohasekai.sagernet.bg
+
+import android.app.Service
+import android.content.Intent
+
+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 fun onBind(intent: Intent) = super.onBind(intent)
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int =
+ super.onStartCommand(intent, flags, startId)
+
+ override fun onDestroy() {
+ super.onDestroy()
+ data.binder.close()
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/SagerConnection.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/SagerConnection.kt
new file mode 100644
index 000000000..a3e7c8cfe
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/bg/SagerConnection.kt
@@ -0,0 +1,149 @@
+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 com.github.shadowsocks.aidl.IShadowsocksService
+import com.github.shadowsocks.aidl.IShadowsocksServiceCallback
+import com.github.shadowsocks.aidl.TrafficStats
+import io.nekohasekai.sagernet.Action
+import io.nekohasekai.sagernet.Key
+import io.nekohasekai.sagernet.database.DataStore
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.launch
+
+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 {
+ fun stateChanged(state: BaseService.State, profileName: String?, msg: String?)
+ fun trafficUpdated(profileId: Long, stats: TrafficStats) {}
+ fun trafficPersisted(profileId: Long) {}
+
+ fun onServiceConnected(service: IShadowsocksService)
+
+ /**
+ * 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 : IShadowsocksServiceCallback.Stub() {
+ override fun stateChanged(state: Int, profileName: String?, msg: String?) {
+ val callback = callback ?: return
+ GlobalScope.launch(Dispatchers.Main.immediate) {
+ callback.stateChanged(BaseService.State.values()[state], profileName, msg)
+ }
+ }
+
+ override fun trafficUpdated(profileId: Long, stats: TrafficStats) {
+ val callback = callback ?: return
+ GlobalScope.launch(Dispatchers.Main.immediate) {
+ callback.trafficUpdated(profileId,
+ stats)
+ }
+ }
+
+ override fun trafficPersisted(profileId: Long) {
+ val callback = callback ?: return
+ GlobalScope.launch(Dispatchers.Main.immediate) { callback.trafficPersisted(profileId) }
+ }
+ }
+ private var binder: IBinder? = null
+
+ var bandwidthTimeout = 0L
+ set(value) {
+ try {
+ if (value > 0) service?.startListeningForBandwidth(serviceCallback, value)
+ else service?.stopListeningForBandwidth(serviceCallback)
+ } catch (_: RemoteException) {
+ }
+ field = value
+ }
+ var service: IShadowsocksService? = null
+
+ override fun onServiceConnected(name: ComponentName?, binder: IBinder) {
+ this.binder = binder
+ val service = IShadowsocksService.Stub.asInterface(binder)!!
+ this.service = service
+ try {
+ if (listenForDeath) binder.linkToDeath(this, 0)
+ check(!callbackRegistered)
+ service.registerCallback(serviceCallback)
+ callbackRegistered = true
+ if (bandwidthTimeout > 0) service.startListeningForBandwidth(serviceCallback,
+ bandwidthTimeout)
+ } 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 { GlobalScope.launch(Dispatchers.Main.immediate) { 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
+ try {
+ service?.stopListeningForBandwidth(serviceCallback)
+ } catch (_: RemoteException) {
+ }
+ service = null
+ callback = null
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/ServiceNotification.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/ServiceNotification.kt
new file mode 100644
index 000000000..13a748555
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/bg/ServiceNotification.kt
@@ -0,0 +1,111 @@
+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.os.PowerManager
+import android.text.format.Formatter
+import androidx.core.app.NotificationCompat
+import androidx.core.content.ContextCompat
+import androidx.core.content.getSystemService
+import com.github.shadowsocks.aidl.IShadowsocksServiceCallback
+import com.github.shadowsocks.aidl.TrafficStats
+import io.nekohasekai.sagernet.Action
+import io.nekohasekai.sagernet.R
+import io.nekohasekai.sagernet.SagerApp
+
+/**
+ * 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() {
+ private val callback: IShadowsocksServiceCallback by lazy {
+ object : IShadowsocksServiceCallback.Stub() {
+ override fun stateChanged(state: Int, profileName: String?, msg: String?) {} // ignore
+ override fun trafficUpdated(profileId: Long, stats: TrafficStats) {
+ if (profileId != 0L) return
+ builder.apply {
+ setContentText((service as Context).getString(R.string.traffic,
+ service.getString(R.string.speed,
+ Formatter.formatFileSize(service, stats.txRate)),
+ service.getString(R.string.speed,
+ Formatter.formatFileSize(service, stats.rxRate))))
+ setSubText(service.getString(R.string.traffic,
+ Formatter.formatFileSize(service, stats.txTotal),
+ Formatter.formatFileSize(service, stats.rxTotal)))
+ }
+ show()
+ }
+
+ override fun trafficPersisted(profileId: Long) {}
+ }
+ }
+ private var callbackRegistered = false
+
+ private val builder = NotificationCompat.Builder(service as Context, channel)
+ .setWhen(0)
+ .setColor(ContextCompat.getColor(service, R.color.material_primary_500))
+ .setTicker(service.getString(R.string.forward_success))
+ .setContentTitle(profileName)
+ .setContentIntent(SagerApp.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
+ val closeAction = NotificationCompat.Action.Builder(
+ R.drawable.ic_navigation_close,
+ service.getText(R.string.stop),
+ PendingIntent.getBroadcast(service,
+ 0,
+ Intent(Action.CLOSE).setPackage(service.packageName),
+ 0)).apply {
+ setShowsUserInterface(false)
+ }.build()
+ if (Build.VERSION.SDK_INT < 24) builder.addAction(closeAction) else builder.addInvisibleAction(
+ closeAction)
+ updateCallback(service.getSystemService()?.isInteractive != false)
+ service.registerReceiver(this, IntentFilter().apply {
+ addAction(Intent.ACTION_SCREEN_ON)
+ addAction(Intent.ACTION_SCREEN_OFF)
+ })
+ show()
+ }
+
+ 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 (screenOn) {
+ service.data.binder.registerCallback(callback)
+ service.data.binder.startListeningForBandwidth(callback, 1000)
+ callbackRegistered = true
+ } else if (callbackRegistered) { // unregister callback to save battery
+ service.data.binder.unregisterCallback(callback)
+ callbackRegistered = false
+ }
+ }
+
+ private fun show() = (service as Service).startForeground(1, builder.build())
+
+ fun destroy() {
+ (service as Service).unregisterReceiver(this)
+ updateCallback(false)
+ service.stopForeground(true)
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/VpnService.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/VpnService.kt
new file mode 100644
index 000000000..ebde001f0
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/bg/VpnService.kt
@@ -0,0 +1,349 @@
+package io.nekohasekai.sagernet.bg
+
+import android.app.Service
+import android.content.Intent
+import android.net.LocalSocket
+import android.net.LocalSocketAddress
+import android.os.Build
+import android.os.ParcelFileDescriptor
+import android.system.ErrnoException
+import android.system.Os
+import io.nekohasekai.sagernet.Key
+import io.nekohasekai.sagernet.R
+import io.nekohasekai.sagernet.SagerApp
+import io.nekohasekai.sagernet.database.DataStore
+import io.nekohasekai.sagernet.ui.VpnRequestActivity
+import io.nekohasekai.sagernet.utils.Subnet
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.delay
+import java.io.File
+import java.io.FileDescriptor
+import java.io.IOException
+import android.net.VpnService as BaseVpnService
+
+class VpnService : BaseVpnService(), BaseService.Interface {
+
+ companion object {
+ private const val VPN_MTU = 1500
+ private const val PRIVATE_VLAN4_CLIENT = "172.19.0.1"
+ private const val PRIVATE_VLAN4_ROUTER = "172.19.0.2"
+ private const val PRIVATE_VLAN6_CLIENT = "fdfe:dcba:9876::1"
+ private const val PRIVATE_VLAN6_ROUTER = "fdfe:dcba:9876::2"
+
+ private val PRIVATE_ROUTES = arrayOf(
+ "1.0.0.0/8",
+ "2.0.0.0/7",
+ "4.0.0.0/6",
+ "8.0.0.0/7",
+ "11.0.0.0/8",
+ "12.0.0.0/6",
+ "16.0.0.0/4",
+ "32.0.0.0/3",
+ "64.0.0.0/3",
+ "96.0.0.0/6",
+ "100.0.0.0/10",
+ "100.128.0.0/9",
+ "101.0.0.0/8",
+ "102.0.0.0/7",
+ "104.0.0.0/5",
+ "112.0.0.0/10",
+ "112.64.0.0/11",
+ "112.96.0.0/12",
+ "112.112.0.0/13",
+ "112.120.0.0/14",
+ "112.124.0.0/19",
+ "112.124.32.0/21",
+ "112.124.40.0/22",
+ "112.124.44.0/23",
+ "112.124.46.0/24",
+ "112.124.48.0/20",
+ "112.124.64.0/18",
+ "112.124.128.0/17",
+ "112.125.0.0/16",
+ "112.126.0.0/15",
+ "112.128.0.0/9",
+ "113.0.0.0/8",
+ "114.0.0.0/10",
+ "114.64.0.0/11",
+ "114.96.0.0/12",
+ "114.112.0.0/15",
+ "114.114.0.0/18",
+ "114.114.64.0/19",
+ "114.114.96.0/20",
+ "114.114.112.0/23",
+ "114.114.115.0/24",
+ "114.114.116.0/22",
+ "114.114.120.0/21",
+ "114.114.128.0/17",
+ "114.115.0.0/16",
+ "114.116.0.0/14",
+ "114.120.0.0/13",
+ "114.128.0.0/9",
+ "115.0.0.0/8",
+ "116.0.0.0/6",
+ "120.0.0.0/6",
+ "124.0.0.0/7",
+ "126.0.0.0/8",
+ "128.0.0.0/3",
+ "160.0.0.0/5",
+ "168.0.0.0/8",
+ "169.0.0.0/9",
+ "169.128.0.0/10",
+ "169.192.0.0/11",
+ "169.224.0.0/12",
+ "169.240.0.0/13",
+ "169.248.0.0/14",
+ "169.252.0.0/15",
+ "169.255.0.0/16",
+ "170.0.0.0/7",
+ "172.0.0.0/12",
+ "172.32.0.0/11",
+ "172.64.0.0/10",
+ "172.128.0.0/9",
+ "173.0.0.0/8",
+ "174.0.0.0/7",
+ "176.0.0.0/4",
+ "192.0.0.8/29",
+ "192.0.0.16/28",
+ "192.0.0.32/27",
+ "192.0.0.64/26",
+ "192.0.0.128/25",
+ "192.0.1.0/24",
+ "192.0.3.0/24",
+ "192.0.4.0/22",
+ "192.0.8.0/21",
+ "192.0.16.0/20",
+ "192.0.32.0/19",
+ "192.0.64.0/18",
+ "192.0.128.0/17",
+ "192.1.0.0/16",
+ "192.2.0.0/15",
+ "192.4.0.0/14",
+ "192.8.0.0/13",
+ "192.16.0.0/12",
+ "192.32.0.0/11",
+ "192.64.0.0/12",
+ "192.80.0.0/13",
+ "192.88.0.0/18",
+ "192.88.64.0/19",
+ "192.88.96.0/23",
+ "192.88.98.0/24",
+ "192.88.100.0/22",
+ "192.88.104.0/21",
+ "192.88.112.0/20",
+ "192.88.128.0/17",
+ "192.89.0.0/16",
+ "192.90.0.0/15",
+ "192.92.0.0/14",
+ "192.96.0.0/11",
+ "192.128.0.0/11",
+ "192.160.0.0/13",
+ "192.169.0.0/16",
+ "192.170.0.0/15",
+ "192.172.0.0/14",
+ "192.176.0.0/12",
+ "192.192.0.0/10",
+ "193.0.0.0/8",
+ "194.0.0.0/7",
+ "196.0.0.0/7",
+ "198.0.0.0/12",
+ "198.16.0.0/15",
+ "198.20.0.0/14",
+ "198.24.0.0/13",
+ "198.32.0.0/12",
+ "198.48.0.0/15",
+ "198.50.0.0/16",
+ "198.51.0.0/18",
+ "198.51.64.0/19",
+ "198.51.96.0/22",
+ "198.51.101.0/24",
+ "198.51.102.0/23",
+ "198.51.104.0/21",
+ "198.51.112.0/20",
+ "198.51.128.0/17",
+ "198.52.0.0/14",
+ "198.56.0.0/13",
+ "198.64.0.0/10",
+ "198.128.0.0/9",
+ "199.0.0.0/8",
+ "200.0.0.0/7",
+ "202.0.0.0/8",
+ "203.0.0.0/18",
+ "203.0.64.0/19",
+ "203.0.96.0/20",
+ "203.0.112.0/24",
+ "203.0.114.0/23",
+ "203.0.116.0/22",
+ "203.0.120.0/21",
+ "203.0.128.0/17",
+ "203.1.0.0/16",
+ "203.2.0.0/15",
+ "203.4.0.0/14",
+ "203.8.0.0/13",
+ "203.16.0.0/12",
+ "203.32.0.0/11",
+ "203.64.0.0/10",
+ "203.128.0.0/9",
+ "204.0.0.0/6",
+ "208.0.0.0/4",
+ )
+
+ private fun FileDescriptor.use(block: (FileDescriptor) -> T) = try {
+ block(this)
+ } finally {
+ try {
+ Os.close(this)
+ } catch (_: ErrnoException) {
+ }
+ }
+ }
+
+ private var conn: ParcelFileDescriptor? = null
+ private var active = false
+ private var metered = false
+
+ override suspend fun startProcesses() {
+ super.startProcesses()
+ sendFd(startVpn())
+ }
+
+ override fun killProcesses(scope: CoroutineScope) {
+ super.killProcesses(scope)
+ active = false
+ conn?.close()
+ }
+
+
+ override fun onBind(intent: Intent) = when (intent.action) {
+ SERVICE_INTERFACE -> super.onBind(intent)
+ else -> super.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.onStartCommand(intent, flags, startId)
+ }
+ stopRunner()
+ return Service.START_NOT_STICKY
+ }
+
+ inner class NullConnectionException : NullPointerException(), BaseService.ExpectedException {
+ override fun getLocalizedMessage() = getString(R.string.reboot_required)
+ }
+
+ private suspend fun startVpn(): FileDescriptor {
+ val profile = data.proxy!!.profile
+ val builder = Builder()
+ .setConfigureIntent(SagerApp.configureIntent(this))
+ .setSession(profile.displayName())
+ .setMtu(VPN_MTU)
+ .addAddress(PRIVATE_VLAN4_CLIENT, 30)
+
+ PRIVATE_ROUTES.forEach {
+ val subnet = Subnet.fromString(it)!!
+ builder.addRoute(subnet.address.hostAddress, subnet.prefixSize)
+ }
+
+ builder.addRoute(PRIVATE_VLAN4_ROUTER, 32)
+ // https://issuetracker.google.com/issues/149636790
+ if (DataStore.ipv6Route) {
+ builder.addRoute("2000::", 3)
+ }
+
+ /* val proxyApps = when (profile.proxyApps) {
+ 0 -> DataStore.proxyApps > 0
+ 1 -> false
+ else -> true
+ }
+ val bypass = when (profile.proxyApps) {
+ 0 -> DataStore.proxyApps == 2
+ 3 -> true
+ else -> false
+ }
+
+ if (proxyApps) {
+
+ val me = packageName
+ (profile.individual ?: DataStore.individual ?: "").split('\n')
+ .filter { it.isNotBlank() && it != me }
+ .forEach {
+ try {
+ if (bypass) builder.addDisallowedApplication(it)
+ else builder.addAllowedApplication(it)
+ } catch (ex: PackageManager.NameNotFoundException) {
+ // Timber.w(ex)
+ }
+ }
+
+ }
+ */
+ builder.addDisallowedApplication("com.github.shadowsocks")
+// builder.addDisallowedApplication(packageName)
+
+ metered = when (profile.meteredNetwork) {
+ 0 -> DataStore.meteredNetwork
+ 1 -> false
+ else -> true
+ }
+ active = true // possible race condition here?
+// builder.setUnderlyingNetworks(underlyingNetworks)
+ if (Build.VERSION.SDK_INT >= 29) builder.setMetered(metered)
+
+ val conn = builder.establish() ?: throw NullConnectionException()
+ this.conn = conn
+
+ val cmd =
+ arrayListOf(File(applicationInfo.nativeLibraryDir, Executable.TUN2SOCKS).canonicalPath,
+ "--netif-ipaddr",
+ PRIVATE_VLAN4_ROUTER,
+ "--socks-server-addr",
+ "127.0.0.1:${DataStore.socks5Port}",
+ "--tunmtu",
+ VPN_MTU.toString(),
+ "--sock-path",
+ File(SagerApp.deviceStorage.noBackupFilesDir, "sock_path").canonicalPath,
+ "--loglevel", "debug")
+ if (DataStore.ipv6Route) {
+ cmd += "--netif-ip6addr"
+ cmd += PRIVATE_VLAN6_ROUTER
+ }
+ // cmd += "--enable-udprelay"
+ data.processes!!.start(cmd, onRestartCallback = {
+ try {
+ sendFd(conn.fileDescriptor)
+ } catch (e: ErrnoException) {
+ stopRunner(false, e.message)
+ }
+ })
+ return conn.fileDescriptor
+ }
+
+ private suspend fun sendFd(fd: FileDescriptor) {
+ var tries = 0
+ val path = File(SagerApp.deviceStorage.noBackupFilesDir, "sock_path").canonicalPath
+ while (true) try {
+ delay(50L shl tries)
+ LocalSocket().use { localSocket ->
+ localSocket.connect(LocalSocketAddress(path,
+ LocalSocketAddress.Namespace.FILESYSTEM))
+ localSocket.setFileDescriptorsForSend(arrayOf(fd))
+ localSocket.outputStream.write(42)
+ }
+ System.out.println("FD Sended")
+ return
+ } catch (e: IOException) {
+ if (tries > 5) throw e
+ tries += 1
+ }
+ }
+
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt b/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt
new file mode 100644
index 000000000..4641ec7e6
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt
@@ -0,0 +1,41 @@
+package io.nekohasekai.sagernet.database
+
+import android.os.Build
+import io.nekohasekai.sagernet.Key
+import io.nekohasekai.sagernet.SagerApp
+import io.nekohasekai.sagernet.database.preference.PublicDatabase
+import io.nekohasekai.sagernet.database.preference.RoomPreferenceDataStore
+import io.nekohasekai.sagernet.ktx.boolean
+import io.nekohasekai.sagernet.ktx.int
+import io.nekohasekai.sagernet.ktx.long
+import io.nekohasekai.sagernet.ktx.string
+import kotlinx.coroutines.DEBUG_PROPERTY_NAME
+import kotlinx.coroutines.DEBUG_PROPERTY_VALUE_ON
+
+object DataStore {
+
+ val publicStore = RoomPreferenceDataStore(PublicDatabase.kvPairDao)
+ val sagerStore = RoomPreferenceDataStore(SagerDatabase.kvPairDao)
+
+ fun init() {
+ if (Build.VERSION.SDK_INT >= 24) {
+ SagerApp.deviceStorage.moveDatabaseFrom(SagerApp.application, Key.DB_PUBLIC)
+ }
+
+ System.setProperty(DEBUG_PROPERTY_NAME, DEBUG_PROPERTY_VALUE_ON)
+
+ }
+
+ var serviceMode by sagerStore.int(Key.SERVICE_MODE)
+ var selectedProxy by sagerStore.long("selected_proxy")
+ var allowAccess by sagerStore.boolean("allow_access")
+ var socks5Port by sagerStore.int("socks5_port") { 3389 }
+ var useHttp by sagerStore.boolean("use_http")
+ var httpPort by sagerStore.long("http_port")
+ var ipv6Route by sagerStore.boolean("ipv6_route")
+ var preferIpv6 by sagerStore.boolean("prefer_ipv6")
+ var meteredNetwork by sagerStore.boolean("metered_network")
+ var proxyApps by sagerStore.int("proxyApps")
+ var individual by sagerStore.string("individual")
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/ProxyEntity.kt b/app/src/main/java/io/nekohasekai/sagernet/database/ProxyEntity.kt
new file mode 100644
index 000000000..9e8c65020
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/database/ProxyEntity.kt
@@ -0,0 +1,65 @@
+package io.nekohasekai.sagernet.database
+
+import androidx.room.*
+import io.nekohasekai.sagernet.fmt.AbstractBean
+import io.nekohasekai.sagernet.fmt.socks.SOCKSBean
+import io.nekohasekai.sagernet.fmt.v2ray.VMessBean
+
+@Entity(tableName = "proxy_entities", indices = [
+ Index("groupId", name = "groupId")
+])
+class ProxyEntity(
+ @PrimaryKey(autoGenerate = true)
+ var id: Long = 0L,
+ var groupId: Long,
+ var type: String,
+ var userOrder: Long = 0L,
+ var tx: Long = 0L,
+ var rx: Long = 0L,
+ var proxyApps: Int = 0,
+ var individual: String? = null,
+ var meteredNetwork: Int = 0,
+ var vmessBean: VMessBean? = null,
+ var socksBean: SOCKSBean? = null,
+) {
+
+ fun displayType(): String {
+ return when (type) {
+ "vmess" -> "VMess"
+ "socks" -> "SOCKS5"
+ else -> "Undefined type $type"
+ }
+ }
+
+ fun displayName(): String {
+ return requireBean().name
+ }
+
+ fun requireBean(): AbstractBean {
+ return when (type) {
+ "vmess" -> vmessBean ?: error("Null vmess node")
+ "socks" -> socksBean ?: error("Null socks node")
+ else -> error("Undefined type $type")
+ }
+ }
+
+ fun requireVMess() = requireBean() as VMessBean
+ fun requireSOCKS() = requireBean() as SOCKSBean
+
+ @androidx.room.Dao
+ interface Dao {
+
+ @Query("SELECT * FROM proxy_entities WHERE groupId = :groupId ORDER BY userOrder")
+ fun getByGroup(groupId: Long): List
+
+ @Query("SELECT * FROM proxy_entities WHERE id = :proxyId")
+ fun getById(proxyId: Long): ProxyEntity?
+
+ @Insert
+ fun addProxy(proxy: ProxyEntity)
+
+ @Update
+ fun updateProxy(proxy: ProxyEntity)
+
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/ProxyGroup.kt b/app/src/main/java/io/nekohasekai/sagernet/database/ProxyGroup.kt
new file mode 100644
index 000000000..7da18fa2e
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/database/ProxyGroup.kt
@@ -0,0 +1,36 @@
+package io.nekohasekai.sagernet.database
+
+import androidx.room.*
+import java.util.*
+
+@Entity(tableName = "proxy_groups")
+class ProxyGroup(
+ @PrimaryKey(autoGenerate = true)
+ var id: Long = 0L,
+ var userOrder: Long = 0L,
+ var isDefault: Boolean = false,
+ var name: String? = null,
+ var isSubscription: Boolean = false,
+ var subscriptionLinks: MutableList = LinkedList(),
+ var lastUpdate: Long = 0L,
+ var layout: Int = 0,
+) {
+
+ @androidx.room.Dao
+ interface Dao {
+
+ @Query("SELECT * FROM proxy_groups ORDER BY userOrder")
+ fun allGroups(): List
+
+ @Query("SELECT * FROM proxy_groups WHERE id = :groupId")
+ fun getById(groupId: Long): ProxyGroup?
+
+ @Delete
+ fun delete(group: ProxyGroup)
+
+ @Insert
+ fun createGroup(group: ProxyGroup)
+
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/SagerDatabase.kt b/app/src/main/java/io/nekohasekai/sagernet/database/SagerDatabase.kt
new file mode 100644
index 000000000..a47dde1fb
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/database/SagerDatabase.kt
@@ -0,0 +1,42 @@
+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.SagerApp
+import io.nekohasekai.sagernet.database.preference.KeyValuePair
+import io.nekohasekai.sagernet.fmt.KryoConverters
+import io.nekohasekai.sagernet.fmt.gson.GsonConverters
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.launch
+
+@Database(entities = [ProxyGroup::class, ProxyEntity::class, KeyValuePair::class], version = 1)
+@TypeConverters(value = [KryoConverters::class, GsonConverters::class])
+@GenerateRoomMigrations
+abstract class SagerDatabase : RoomDatabase() {
+
+ companion object {
+ private val instance by lazy {
+ Room.databaseBuilder(SagerApp.application, SagerDatabase::class.java, Key.DB_PROFILE)
+ .addMigrations(*SagerDatabase_Migrations.build())
+ .allowMainThreadQueries()
+ .enableMultiInstanceInvalidation()
+ .fallbackToDestructiveMigration()
+ .setQueryExecutor { GlobalScope.launch { it.run() } }
+ .build()
+ }
+
+ val kvPairDao get() = instance.keyValuePairDao()
+ val groupDao get() = instance.groupDao()
+ val proxyDao get() = instance.proxyDao()
+
+ }
+
+ abstract fun keyValuePairDao(): KeyValuePair.Dao
+ abstract fun groupDao(): ProxyGroup.Dao
+ abstract fun proxyDao(): ProxyEntity.Dao
+
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/preference/EditTextPreferenceModifiers.kt b/app/src/main/java/io/nekohasekai/sagernet/database/preference/EditTextPreferenceModifiers.kt
new file mode 100644
index 000000000..185e66c6d
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/database/preference/EditTextPreferenceModifiers.kt
@@ -0,0 +1,28 @@
+
+
+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 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)
+ }
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/preference/KeyValuePair.kt b/app/src/main/java/io/nekohasekai/sagernet/database/preference/KeyValuePair.kt
new file mode 100644
index 000000000..606d495d7
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/database/preference/KeyValuePair.kt
@@ -0,0 +1,119 @@
+package io.nekohasekai.sagernet.database.preference
+
+import androidx.room.*
+import java.io.ByteArrayOutputStream
+import java.nio.ByteBuffer
+
+@Entity
+class KeyValuePair() {
+ 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
+ }
+
+ @androidx.room.Dao
+ interface Dao {
+ @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
+ }
+
+ @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?
+ get() = if (valueType == TYPE_STRING_SET) {
+ val buffer = ByteBuffer.wrap(value)
+ val result = HashSet()
+ 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): 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
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/preference/OnPreferenceDataStoreChangeListener.kt b/app/src/main/java/io/nekohasekai/sagernet/database/preference/OnPreferenceDataStoreChangeListener.kt
new file mode 100644
index 000000000..9cca6d301
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/database/preference/OnPreferenceDataStoreChangeListener.kt
@@ -0,0 +1,7 @@
+package io.nekohasekai.sagernet.database.preference
+
+import androidx.preference.PreferenceDataStore
+
+interface OnPreferenceDataStoreChangeListener {
+ fun onPreferenceDataStoreChanged(store: PreferenceDataStore, key: String)
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/preference/PublicDatabase.kt b/app/src/main/java/io/nekohasekai/sagernet/database/preference/PublicDatabase.kt
new file mode 100644
index 000000000..6b772b016
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/database/preference/PublicDatabase.kt
@@ -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.SagerApp
+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 {
+ Room.databaseBuilder(SagerApp.deviceStorage, PublicDatabase::class.java, Key.DB_PUBLIC)
+ .addMigrations(*PublicDatabase_Migrations.build())
+ .allowMainThreadQueries()
+ .enableMultiInstanceInvalidation()
+ .fallbackToDestructiveMigration()
+ .setQueryExecutor { GlobalScope.launch { it.run() } }
+ .build()
+ }
+
+ val kvPairDao get() = instance.keyValuePairDao()
+ }
+
+ abstract fun keyValuePairDao(): KeyValuePair.Dao
+
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/database/preference/RoomPreferenceDataStore.kt b/app/src/main/java/io/nekohasekai/sagernet/database/preference/RoomPreferenceDataStore.kt
new file mode 100644
index 000000000..885a4e5eb
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/database/preference/RoomPreferenceDataStore.kt
@@ -0,0 +1,80 @@
+package io.nekohasekai.sagernet.database.preference
+
+import androidx.preference.PreferenceDataStore
+import java.util.*
+
+@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
+
+ 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?) =
+ 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?) =
+ 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()
+ private fun fireChangeListener(key: String) =
+ listeners.forEach { it.onPreferenceDataStoreChanged(this, key) }
+
+ fun registerChangeListener(listener: OnPreferenceDataStoreChangeListener) =
+ listeners.add(listener)
+
+ fun unregisterChangeListener(listener: OnPreferenceDataStoreChangeListener) =
+ listeners.remove(listener)
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/AbstractBean.java b/app/src/main/java/io/nekohasekai/sagernet/fmt/AbstractBean.java
new file mode 100644
index 000000000..a32584db4
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/AbstractBean.java
@@ -0,0 +1,25 @@
+package io.nekohasekai.sagernet.fmt;
+
+import com.esotericsoftware.kryo.io.ByteBufferInput;
+import com.esotericsoftware.kryo.io.ByteBufferOutput;
+
+public class AbstractBean {
+
+ public String serverAddress;
+ public int serverPort;
+
+ public String name;
+
+ public void serialize(ByteBufferOutput output) {
+ output.writeString(name);
+ output.writeString(serverAddress);
+ output.writeInt(serverPort);
+ }
+
+ public void deserialize(ByteBufferInput input) {
+ name = input.readString();
+ serverAddress = input.readString();
+ serverPort = input.readInt();
+ }
+
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/KryoConverters.java b/app/src/main/java/io/nekohasekai/sagernet/fmt/KryoConverters.java
new file mode 100644
index 000000000..1692ae2fb
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/KryoConverters.java
@@ -0,0 +1,55 @@
+package io.nekohasekai.sagernet.fmt;
+
+import androidx.room.TypeConverter;
+
+import com.esotericsoftware.kryo.io.ByteBufferInput;
+import com.esotericsoftware.kryo.io.ByteBufferOutput;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+
+import cn.hutool.core.io.IoUtil;
+import cn.hutool.core.util.ArrayUtil;
+import io.nekohasekai.sagernet.fmt.socks.SOCKSBean;
+import io.nekohasekai.sagernet.fmt.v2ray.VMessBean;
+import io.nekohasekai.sagernet.ktx.KryosKt;
+
+public class KryoConverters {
+
+ private static final byte[] NULL = new byte[0];
+
+ @TypeConverter
+ public static byte[] serialize(AbstractBean bean) {
+ if (bean == null) return NULL;
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ ByteBufferOutput buffer = KryosKt.byteBuffer(out);
+ bean.serialize(buffer);
+ IoUtil.flush(buffer);
+ IoUtil.flush(out);
+ IoUtil.close(buffer);
+ IoUtil.close(out);
+ return out.toByteArray();
+ }
+
+ private static T deserialize(T bean, byte[] bytes) {
+ ByteArrayInputStream input = new ByteArrayInputStream(bytes);
+ ByteBufferInput buffer = KryosKt.byteBuffer(input);
+ bean.deserialize(buffer);
+ IoUtil.close(buffer);
+ IoUtil.close(input);
+ return bean;
+ }
+
+ @TypeConverter
+ public static VMessBean vmessDeserialize(byte[] bytes) {
+ if (ArrayUtil.isEmpty(bytes)) return null;
+ return deserialize(new VMessBean(), bytes);
+ }
+
+ @TypeConverter
+ public static SOCKSBean socksDeserialize(byte[] bytes) {
+ if (ArrayUtil.isEmpty(bytes)) return null;
+ return deserialize(new SOCKSBean(), bytes);
+ }
+
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/gson/GsonConverters.java b/app/src/main/java/io/nekohasekai/sagernet/fmt/gson/GsonConverters.java
new file mode 100644
index 000000000..57af9e666
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/gson/GsonConverters.java
@@ -0,0 +1,19 @@
+package io.nekohasekai.sagernet.fmt.gson;
+
+import androidx.room.TypeConverter;
+
+import java.util.List;
+
+public class GsonConverters {
+
+ @TypeConverter
+ public static String toJson(Object value) {
+ return GsonsKt.getGson().toJson(value);
+ }
+
+ @TypeConverter
+ public static List toList(String value) {
+ return GsonsKt.getGson().fromJson(value, List.class);
+ }
+
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/gson/Gsons.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/gson/Gsons.kt
new file mode 100644
index 000000000..97780dbfc
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/gson/Gsons.kt
@@ -0,0 +1,8 @@
+package io.nekohasekai.sagernet.fmt.gson
+
+import com.google.gson.GsonBuilder
+
+val gson = GsonBuilder()
+ .registerTypeAdapterFactory(JsonOrAdapterFactory())
+ .registerTypeAdapterFactory(JsonLazyFactory())
+ .create()
\ No newline at end of file
diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/gson/JsonLazyAdapter.java b/app/src/main/java/io/nekohasekai/sagernet/fmt/gson/JsonLazyAdapter.java
new file mode 100644
index 000000000..ac15db50c
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/gson/JsonLazyAdapter.java
@@ -0,0 +1,42 @@
+package io.nekohasekai.sagernet.fmt.gson;
+
+
+import com.google.gson.Gson;
+import com.google.gson.JsonElement;
+import com.google.gson.TypeAdapter;
+import com.google.gson.stream.JsonReader;
+import com.google.gson.stream.JsonWriter;
+
+import java.io.IOException;
+
+public class JsonLazyAdapter extends TypeAdapter> {
+
+ private final Gson gson;
+ private final Class> clazz;
+
+ public JsonLazyAdapter(Gson gson, Class> clazz) {
+ this.gson = gson;
+ this.clazz = clazz;
+ }
+
+ @Override
+ public void write(JsonWriter out, JsonLazyInterface value) throws IOException {
+ gson.getAdapter(value.type.getValue()).write(out, value.getValue());
+ }
+
+ @Override
+ public JsonLazyInterface read(JsonReader in) throws IOException {
+ try {
+ JsonLazyInterface instance = clazz.newInstance();
+ instance.gson = gson;
+ instance.content = gson.getAdapter(JsonElement.class).read(in);
+ return instance;
+ } catch (Exception e) {
+ if (e instanceof IOException) {
+ throw ((IOException) e);
+ } else {
+ throw new IOException(e);
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/gson/JsonLazyFactory.java b/app/src/main/java/io/nekohasekai/sagernet/fmt/gson/JsonLazyFactory.java
new file mode 100644
index 000000000..881dab770
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/gson/JsonLazyFactory.java
@@ -0,0 +1,17 @@
+package io.nekohasekai.sagernet.fmt.gson;
+
+import com.google.gson.Gson;
+import com.google.gson.TypeAdapter;
+import com.google.gson.TypeAdapterFactory;
+import com.google.gson.reflect.TypeToken;
+
+public class JsonLazyFactory implements TypeAdapterFactory {
+
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ @Override
+ public TypeAdapter create(Gson gson, TypeToken type) {
+ if (!JsonLazyInterface.class.isAssignableFrom(type.getRawType())) return null;
+ return new JsonLazyAdapter(gson, type.getRawType());
+ }
+
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/gson/JsonLazyInterface.java b/app/src/main/java/io/nekohasekai/sagernet/fmt/gson/JsonLazyInterface.java
new file mode 100644
index 000000000..417d5faff
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/gson/JsonLazyInterface.java
@@ -0,0 +1,52 @@
+package io.nekohasekai.sagernet.fmt.gson;
+
+import androidx.annotation.Nullable;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonElement;
+
+import kotlin.Lazy;
+import kotlin.LazyKt;
+
+@SuppressWarnings("unchecked")
+public abstract class JsonLazyInterface implements Lazy {
+
+ protected JsonElement content;
+ protected Gson gson;
+ private T value;
+ private boolean fromValue;
+
+ public JsonLazyInterface() {
+ }
+
+ public JsonLazyInterface(T value) {
+ this.value = value;
+ this.fromValue = true;
+ }
+
+ protected final Lazy> type = LazyKt.lazy(() -> (Class) getType());
+ private final Lazy _value = LazyKt.lazy(this::init);
+
+ private T init() {
+ if (type.getValue() == null) {
+ return null;
+ }
+ return gson.fromJson(content, type.getValue());
+ }
+
+ @Nullable
+ protected abstract Class extends T> getType();
+
+ @Override
+ public T getValue() {
+ if (fromValue) return value;
+ return _value.getValue();
+ }
+
+ @Override
+ public boolean isInitialized() {
+ if (fromValue) return true;
+ return _value.isInitialized();
+ }
+
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/gson/JsonOr.java b/app/src/main/java/io/nekohasekai/sagernet/fmt/gson/JsonOr.java
new file mode 100644
index 000000000..50f74fe4c
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/gson/JsonOr.java
@@ -0,0 +1,30 @@
+package io.nekohasekai.sagernet.fmt.gson;
+
+import androidx.annotation.NonNull;
+
+import com.google.gson.stream.JsonToken;
+
+public class JsonOr {
+
+ public JsonToken tokenX;
+ public JsonToken tokenY;
+
+ public X valueX;
+ public Y valueY;
+
+ public JsonOr(JsonToken tokenX, JsonToken tokenY) {
+ this.tokenX = tokenX;
+ this.tokenY = tokenY;
+ }
+
+ protected JsonOr(X valueX, Y valueY) {
+ this.valueX = valueX;
+ this.valueY = valueY;
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ return valueX != null ? valueX.toString() : valueY != null ? valueY.toString() : "null";
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/gson/JsonOrAdapter.java b/app/src/main/java/io/nekohasekai/sagernet/fmt/gson/JsonOrAdapter.java
new file mode 100644
index 000000000..813f29c29
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/gson/JsonOrAdapter.java
@@ -0,0 +1,46 @@
+package io.nekohasekai.sagernet.fmt.gson;
+
+import com.google.gson.Gson;
+import com.google.gson.TypeAdapter;
+import com.google.gson.reflect.TypeToken;
+import com.google.gson.stream.JsonReader;
+import com.google.gson.stream.JsonToken;
+import com.google.gson.stream.JsonWriter;
+
+import java.io.IOException;
+
+public class JsonOrAdapter extends TypeAdapter> {
+
+ private final Gson gson;
+ private final TypeToken typeX;
+ private final TypeToken typeY;
+ private final JsonToken tokenX;
+ private final JsonToken tokenY;
+
+ public JsonOrAdapter(Gson gson, TypeToken typeX, TypeToken typeY, JsonToken tokenX, JsonToken tokenY) {
+ this.gson = gson;
+ this.typeX = typeX;
+ this.typeY = typeY;
+ this.tokenX = tokenX;
+ this.tokenY = tokenY;
+ }
+
+ @Override
+ public void write(JsonWriter out, JsonOr value) throws IOException {
+ if (value.valueX != null) {
+ gson.getAdapter(typeX).write(out, value.valueX);
+ } else {
+ gson.getAdapter(typeY).write(out, value.valueY);
+ }
+ }
+
+ @Override
+ public JsonOr read(JsonReader in) throws IOException {
+ if (in.peek() == tokenX) {
+ return new JsonOr<>(gson.getAdapter(typeX).read(in), null);
+ } else {
+ return new JsonOr<>(null, gson.getAdapter(typeY).read(in));
+ }
+
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/gson/JsonOrAdapterFactory.java b/app/src/main/java/io/nekohasekai/sagernet/fmt/gson/JsonOrAdapterFactory.java
new file mode 100644
index 000000000..baf6ea923
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/gson/JsonOrAdapterFactory.java
@@ -0,0 +1,32 @@
+package io.nekohasekai.sagernet.fmt.gson;
+
+import com.google.gson.Gson;
+import com.google.gson.TypeAdapter;
+import com.google.gson.TypeAdapterFactory;
+import com.google.gson.reflect.TypeToken;
+
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
+
+public class JsonOrAdapterFactory implements TypeAdapterFactory {
+
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ @Override
+ public TypeAdapter create(Gson gson, TypeToken type) {
+ if (!JsonOr.class.isAssignableFrom(type.getRawType())) return null;
+ Type superclass = type.getRawType().getGenericSuperclass();
+ if (superclass instanceof Class) {
+ throw new RuntimeException("Missing type parameter.");
+ }
+ ParameterizedType parameterized = (ParameterizedType) superclass;
+ Type[] args = parameterized.getActualTypeArguments();
+ try {
+ JsonOr, ?> instance = (JsonOr, ?>) type.getRawType().newInstance();
+ return new JsonOrAdapter(gson, TypeToken.get(args[0]), TypeToken.get(args[1]), instance.tokenX, instance.tokenY);
+ } catch (Exception e) {
+ e.printStackTrace();
+ throw new RuntimeException(e);
+ }
+ }
+
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/socks/SOCKSBean.java b/app/src/main/java/io/nekohasekai/sagernet/fmt/socks/SOCKSBean.java
new file mode 100644
index 000000000..36ece1851
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/socks/SOCKSBean.java
@@ -0,0 +1,31 @@
+package io.nekohasekai.sagernet.fmt.socks;
+
+import com.esotericsoftware.kryo.io.ByteBufferInput;
+import com.esotericsoftware.kryo.io.ByteBufferOutput;
+
+import io.nekohasekai.sagernet.fmt.AbstractBean;
+
+public class SOCKSBean extends AbstractBean {
+
+ public String username;
+ public String password;
+ public boolean udp;
+
+ @Override
+ public void serialize(ByteBufferOutput output) {
+ output.writeInt(0);
+ super.serialize(output);
+ output.writeString(username);
+ output.writeString(password);
+ output.writeBoolean(udp);
+ }
+
+ @Override
+ public void deserialize(ByteBufferInput input) {
+ int version = input.readInt();
+ super.deserialize(input);
+ username = input.readString();
+ password = input.readString();
+ udp = input.readBoolean();
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/socks/SOCKSFmt.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/socks/SOCKSFmt.kt
new file mode 100644
index 000000000..eef563bdf
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/socks/SOCKSFmt.kt
@@ -0,0 +1,35 @@
+package io.nekohasekai.sagernet.fmt.socks
+
+import okhttp3.HttpUrl
+import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
+
+fun parseSOCKS5(link: String): SOCKSBean {
+ val url = ("https://" + link
+ .substringAfter("://"))
+ .toHttpUrlOrNull() ?: error("Not supported: $link")
+
+ return SOCKSBean().apply {
+ serverAddress = url.host
+ serverPort = url.port
+ username = url.username
+ password = url.password
+ name = url.fragment
+ udp = url.queryParameter("udp") == "true"
+ }
+}
+
+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.fragment(name)
+ if (udp) builder.addQueryParameter("udp", "true")
+
+ return builder.build().toString().replaceRange(0..4, "socks5")
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/V2rayConfig.java b/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/V2rayConfig.java
new file mode 100644
index 000000000..3ee1a4e5a
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/V2rayConfig.java
@@ -0,0 +1,693 @@
+package io.nekohasekai.sagernet.fmt.v2ray;
+
+import androidx.annotation.Nullable;
+
+import com.google.gson.InstanceCreator;
+import com.google.gson.annotations.SerializedName;
+import com.google.gson.stream.JsonToken;
+
+import java.lang.reflect.Type;
+import java.util.List;
+import java.util.Map;
+
+import io.nekohasekai.sagernet.fmt.gson.JsonLazyInterface;
+import io.nekohasekai.sagernet.fmt.gson.JsonOr;
+
+@SuppressWarnings({"SpellCheckingInspection", "unused", "RedundantSuppression"})
+public class V2rayConfig {
+
+ public LogObject log;
+
+ public static class LogObject {
+
+ public String access;
+ public String error;
+ public String loglevel;
+
+ }
+
+ public ApiObject api;
+
+ public static class ApiObject {
+
+ public String tag;
+ public List services;
+
+ }
+
+ public DnsObject dns;
+
+ public static class DnsObject {
+
+ public Map hosts;
+
+ public List servers;
+
+ public static class ServerObject {
+
+ public String address;
+ public Integer port;
+ public String clientIp;
+
+ }
+
+ public static class StringOrServerObject extends JsonOr {
+ public StringOrServerObject() {
+ super(JsonToken.STRING, JsonToken.BEGIN_OBJECT);
+ }
+ }
+
+ public String clientIp;
+ public Boolean disableCache;
+ public String tag;
+ public List domains;
+ public List expectIPs;
+
+ }
+
+ public RoutingObject routing;
+
+ public static class RoutingObject {
+
+ public String domainStrategy;
+ public List rules;
+
+ public static class RuleObject {
+
+ public String type;
+ public List domain;
+ public List ip;
+ public String port;
+ public String sourcePort;
+ public String network;
+ public List source;
+ public List user;
+ public List inboundTag;
+ public List protocol;
+ public String attrs;
+ public String outboundTag;
+ public String balancerTag;
+
+ }
+
+ public List balancers;
+
+ public static class BalancerObject {
+
+ public String tag;
+ public List selector;
+
+ }
+
+ }
+
+ public PolicyObject policy;
+
+ public static class PolicyObject {
+
+ public Map levels;
+
+ public static class LevelPolicyObject {
+
+ public Integer handshake;
+ public Integer connIdle;
+ public Integer uplinkOnly;
+ public Integer downlinkOnly;
+ public Boolean statsUserUplink;
+ public Boolean statsUserDownlink;
+ public Integer bufferSize;
+
+ }
+
+ public SystemPolicyObject system;
+
+ public static class SystemPolicyObject {
+
+ public Boolean statsInboundUplink;
+ public Boolean statsInboundDownlink;
+ public Boolean statsOutboundUplink;
+ public Boolean statsOutboundDownlink;
+
+ }
+
+ }
+
+ public List inbounds;
+
+ public static class InboundObject implements InstanceCreator {
+
+ public String listen;
+ public Integer port;
+ public String protocol;
+ public LazyInboundConfigurationObject settings;
+ public StreamSettingsObject streamSettings;
+ public String tag;
+ public SniffingObject sniffing;
+ public AllocateObject allocate;
+
+ public static class SniffingObject {
+
+ public Boolean enabled;
+ public List destOverride;
+ public Boolean metadataOnly;
+
+ }
+
+ public static class AllocateObject {
+
+ public String strategy;
+ public Integer refresh;
+ public Integer concurrency;
+
+ }
+
+ @Override
+ public LazyInboundConfigurationObject createInstance(Type type) {
+ return new LazyInboundConfigurationObject();
+ }
+
+ public class LazyInboundConfigurationObject extends JsonLazyInterface {
+
+ public LazyInboundConfigurationObject() {
+ }
+
+ public LazyInboundConfigurationObject(InboundConfigurationObject value) {
+ super(value);
+ }
+
+ @Nullable
+ @Override
+ protected Class extends InboundConfigurationObject> getType() {
+ switch (protocol.toLowerCase()) {
+ case "dokodemo-door":
+ return DokodemoDoorInboundConfigurationObject.class;
+ case "http":
+ return HTTPInboundConfigurationObject.class;
+ case "socks":
+ return SocksInboundConfigurationObject.class;
+ case "vmess":
+ return VMessInboundConfigurationObject.class;
+ case "vless":
+ return VLESSInboundConfigurationObject.class;
+ case "shadowsocks":
+ return ShadowsocksInboundConfigurationObject.class;
+ case "trojan":
+ return TrojanInboundConfigurationObject.class;
+
+ }
+ return null;
+ }
+
+ }
+
+ }
+
+ public interface InboundConfigurationObject {
+ }
+
+ public static class DokodemoDoorInboundConfigurationObject implements InboundConfigurationObject {
+
+ public String address;
+ public Integer port;
+ public String network;
+ public Integer timeout;
+ public Boolean followRedirect;
+ public Integer userLevel;
+
+ }
+
+ public static class HTTPInboundConfigurationObject implements InboundConfigurationObject {
+
+ public Integer timeout;
+ public List accounts;
+ public Boolean allowTransparent;
+ public Integer userLevel;
+
+ public static class AccountObject {
+
+ public String user;
+ public String pass;
+
+ }
+
+ }
+
+ public static class SocksInboundConfigurationObject implements InboundConfigurationObject {
+
+
+ public String auth;
+ public List accounts;
+ public Boolean udp;
+ public String ip;
+ public Integer userLevel;
+
+ public static class AccountObject {
+
+ public String user;
+ public String pass;
+
+ }
+
+ }
+
+ public static class VMessInboundConfigurationObject implements InboundConfigurationObject {
+
+ public List clients;
+ @SerializedName("default")
+ public DefaultObject defaultObject;
+ public DetourObject detour;
+ public Boolean disableInsecureEncryption;
+
+
+ public static class ClientObject {
+
+ public String id;
+ public Integer level;
+ public Integer alterId;
+ public String email;
+
+ }
+
+ public static class DefaultObject {
+
+ public Integer level;
+ public Integer alterId;
+
+ }
+
+ public static class DetourObject {
+
+ public String to;
+
+ }
+
+ }
+
+ public static class VLESSInboundConfigurationObject implements InboundConfigurationObject {
+
+ public List clients;
+ public String decryption;
+ public List fallbacks;
+
+ public static class ClientObject {
+
+ public String id;
+ public Integer level;
+ public String email;
+
+ }
+
+ public static class FallbackObject {
+
+ public String alpn;
+ public String path;
+ public Integer dest;
+ public Integer xver;
+
+ }
+
+ }
+
+ public static class ShadowsocksInboundConfigurationObject implements InboundConfigurationObject {
+
+ public String email;
+ public String method;
+ public String password;
+ public Integer level;
+ public String network;
+
+ }
+
+ public static class TrojanInboundConfigurationObject implements InboundConfigurationObject {
+
+ public List clients;
+ public List fallbacks;
+
+ public static class ClientObject {
+
+ public String password;
+ public String email;
+ public Integer level;
+
+ }
+
+ public static class FallbackObject {
+
+ public String alpn;
+ public String path;
+ public Integer dest;
+ public Integer xver;
+
+ }
+
+ }
+
+ public List outbounds;
+
+ public static class OutboundObject {
+
+ public String sendThrough;
+ public String protocol;
+ public LazyOutboundConfigurationObject settings;
+ public String tag;
+ public StreamSettingsObject streamSettings;
+ public ProxySettingsObject proxySettings;
+ public MuxObject mux;
+
+ public class LazyOutboundConfigurationObject extends JsonLazyInterface {
+
+ public LazyOutboundConfigurationObject() {
+ }
+
+ public LazyOutboundConfigurationObject(OutboundConfigurationObject value) {
+ super(value);
+ }
+
+ @Nullable
+ @Override
+ protected Class extends OutboundConfigurationObject> getType() {
+ switch (protocol.toLowerCase()) {
+ case "blackhole":
+ return BlackholeOutboundConfigurationObject.class;
+ case "dns":
+ return DNSOutboundConfigurationObject.class;
+ case "freedom":
+ return FreedomOutboundConfigurationObject.class;
+ case "http":
+ return HTTPOutboundConfigurationObject.class;
+ case "socks":
+ return SocksOutboundConfigurationObject.class;
+ case "vmess":
+ return VMessOutboundConfigurationObject.class;
+ case "shadowsocks":
+ return ShadowsocksOutboundConfigurationObject.class;
+ case "vless":
+ return VLESSOutboundConfigurationObject.class;
+ case "loopback":
+ return LoopbackOutboundConfigurationObject.class;
+ }
+ return null;
+ }
+ }
+
+ public static class ProxySettingsObject {
+
+ public String tag;
+ public Boolean transportLayer;
+
+ }
+
+ public static class MuxObject {
+
+ public Boolean enabled;
+ public Integer concurrency;
+
+ }
+
+ }
+
+ public interface OutboundConfigurationObject {
+ }
+
+ public static class BlackholeOutboundConfigurationObject implements OutboundConfigurationObject {
+
+ public ResponseObject response;
+
+ public static class ResponseObject {
+ public String type;
+ }
+
+ }
+
+ public static class DNSOutboundConfigurationObject implements OutboundConfigurationObject {
+
+ public String network;
+ public String address;
+ public Integer port;
+
+ }
+
+ public static class FreedomOutboundConfigurationObject implements OutboundConfigurationObject {
+
+ public String domainStrategy;
+ public String redirect;
+ public Integer userLevel;
+
+
+ }
+
+ public static class HTTPOutboundConfigurationObject implements OutboundConfigurationObject {
+
+ public List servers;
+
+ public static class ServerObject {
+
+ public String address;
+ public Integer port;
+ public List users;
+
+ }
+
+ }
+
+ public static class SocksOutboundConfigurationObject implements OutboundConfigurationObject {
+
+ public List servers;
+
+ public static class ServerObject {
+
+ public String address;
+ public Integer port;
+ public List users;
+
+ public static class UserObject {
+
+ public String user;
+ public String pass;
+ public Integer level;
+
+ }
+
+ }
+
+ }
+
+ public static class VMessOutboundConfigurationObject implements OutboundConfigurationObject {
+
+ public List vnext;
+
+ public static class ServerObject {
+
+ public String address;
+ public Integer port;
+ public UserObject users;
+
+ public static class UserObject {
+
+ public String id;
+ public String alterId;
+ public String security;
+ public Integer level;
+
+ }
+
+ }
+
+ }
+
+ public static class ShadowsocksOutboundConfigurationObject implements OutboundConfigurationObject {
+
+ public List servers;
+
+ public static class ServerObject {
+
+ public String address;
+ public Integer port;
+ public String method;
+ public String password;
+ public Integer level;
+ public String email;
+
+ }
+
+ }
+
+ public static class VLESSOutboundConfigurationObject implements OutboundConfigurationObject {
+
+ public List vnext;
+
+ public static class ServerObject {
+
+ public String address;
+ public Integer port;
+ public UserObject users;
+
+ public static class UserObject {
+
+ public String id;
+ public String encryption;
+ public Integer level;
+
+ }
+
+ }
+
+ }
+
+ public static class LoopbackOutboundConfigurationObject implements OutboundConfigurationObject {
+
+ public String inboundTag;
+
+ }
+
+ public TransportObject transport;
+
+ public static class TransportObject {
+
+ public TLSObject tlsSettings;
+ public TcpObject tcpSettings;
+ public KcpObject kcpSettings;
+ public WebSocketObject wsSettings;
+ public HttpObject httpSettings;
+ public QuicObject quicSettings;
+ public DomainSocketObject dsSettings;
+
+ }
+
+ public static class StreamSettingsObject {
+
+ public String network;
+ public String security;
+ public TLSObject tlsSettings;
+ public TcpObject tcpSettings;
+ public KcpObject kcpSettings;
+ public WebSocketObject wsSettings;
+ public HttpObject httpSettings;
+ public QuicObject quicSettings;
+ public DomainSocketObject dsSettings;
+ public SockoptObject sockopt;
+
+ public static class SockoptObject {
+
+ public Integer mark;
+ public Boolean tcpFastOpen;
+ public String tproxy;
+
+ }
+
+ }
+
+ public static class TLSObject {
+
+ public String serverName;
+ public Boolean allowInsecure;
+ public List alpn;
+ public List certificates;
+ public Boolean disableSystemRoot;
+
+ public static class CertificateObject {
+
+ public String usage;
+ public String certificateFile;
+ public String keyFile;
+ public List certificate;
+ public List key;
+
+ }
+
+ }
+
+ public static class TcpObject {
+
+ public Boolean acceptProxyProtocol;
+ public HeaderObject header;
+
+ public static class HeaderObject {
+
+ public String type;
+
+ public HTTPRequestObject request;
+ public HTTPResponseObject response;
+
+ public static class HTTPRequestObject {
+
+ public String version;
+ public String method;
+ public List path;
+ public Map> headers;
+
+ }
+
+ public static class HTTPResponseObject {
+
+ public String version;
+ public String status;
+ public String reason;
+ public Map> headers;
+
+ }
+
+ }
+
+ }
+
+
+ public static class KcpObject {
+
+ public Integer mtu;
+ public Integer tti;
+ public Integer uplinkCapacity;
+ public Integer downlinkCapacity;
+ public Boolean congestion;
+ public Integer readBufferSize;
+ public Integer writeBufferSize;
+ public HeaderObject header;
+ public String seed;
+
+ public static class HeaderObject {
+
+ public String type;
+
+ }
+
+ }
+
+ public static class WebSocketObject {
+
+ public Boolean acceptProxyProtocol;
+ public String path;
+ public Map headers;
+
+ }
+
+ public static class HttpObject {
+
+ public List host;
+ public String path;
+
+ }
+
+ public static class QuicObject {
+
+ public String security;
+ public String key;
+ public HeaderObject header;
+
+ public static class HeaderObject {
+
+ public String type;
+
+ }
+
+ }
+
+ public static class DomainSocketObject {
+
+ public String path;
+ @SerializedName("abstract")
+ public Boolean isAbstract;
+ public Boolean padding;
+
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/VMessBean.java b/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/VMessBean.java
new file mode 100644
index 000000000..04b889b18
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/VMessBean.java
@@ -0,0 +1,77 @@
+package io.nekohasekai.sagernet.fmt.v2ray;
+
+import com.esotericsoftware.kryo.io.ByteBufferInput;
+import com.esotericsoftware.kryo.io.ByteBufferOutput;
+
+import cn.hutool.core.util.StrUtil;
+import io.nekohasekai.sagernet.fmt.AbstractBean;
+
+public class VMessBean extends AbstractBean {
+
+ public String uuid;
+ public String path;
+
+ public String tag;
+ public boolean tls;
+ public String network;
+ public int kcpUpLinkCapacity;
+ public int kcpDownLinkCapacity;
+ public String header;
+ public int mux;
+
+ // custom
+
+ public String requestHost;
+ public String sni;
+ public String security;
+ public int alterId;
+
+ protected void initDefaultValues() {
+ if (StrUtil.isBlank(network)) {
+ network = "tls";
+ }
+ if (StrUtil.isBlank(security)) {
+ security = "auto";
+ }
+ }
+
+ @Override
+ public void serialize(ByteBufferOutput output) {
+ output.writeInt(0);
+ super.serialize(output);
+ output.writeString(uuid);
+ output.writeString(tag);
+ output.writeBoolean(tls);
+ output.writeString(network);
+ output.writeInt(kcpUpLinkCapacity);
+ output.writeInt(kcpDownLinkCapacity);
+ output.writeString(header);
+ output.writeInt(mux);
+
+ // custom
+ output.writeString(requestHost);
+ output.writeString(sni);
+ output.writeString(security);
+ output.writeInt(alterId);
+ }
+
+ @Override
+ public void deserialize(ByteBufferInput input) {
+ int version = input.readInt();
+ super.deserialize(input);
+ uuid = input.readString();
+ tag = input.readString();
+ tls = input.readBoolean();
+ network = input.readString();
+ kcpUpLinkCapacity = input.readInt();
+ kcpDownLinkCapacity = input.readInt();
+ header = input.readString();
+ mux = input.readInt();
+
+ // custom
+ requestHost = input.readString();
+ sni = input.readString();
+ security = input.readString();
+ alterId = input.readInt();
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/VMessFmt.kt b/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/VMessFmt.kt
new file mode 100644
index 000000000..ac17a5d60
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/VMessFmt.kt
@@ -0,0 +1,245 @@
+package io.nekohasekai.sagernet.fmt.v2ray
+
+import cn.hutool.core.codec.Base64
+import cn.hutool.json.JSONObject
+import io.nekohasekai.sagernet.fmt.AbstractBean
+import io.nekohasekai.sagernet.fmt.gson.gson
+import io.nekohasekai.sagernet.fmt.socks.SOCKSBean
+import io.nekohasekai.sagernet.fmt.v2ray.V2rayConfig.*
+import okhttp3.HttpUrl
+import okhttp3.HttpUrl.Companion.toHttpUrl
+
+fun buildV2rayConfig(bean: AbstractBean, listen: String, port: Int): String {
+
+ return V2rayConfig().apply {
+
+ dns = DnsObject().apply {
+
+ log = LogObject().apply {
+ loglevel = "debug"
+ }
+
+ servers = listOf(
+ DnsObject.StringOrServerObject().apply {
+ valueX = "https+local://doh.dns.sb/dns-query"
+ }
+ )
+
+ policy = PolicyObject().apply {
+ system = PolicyObject.SystemPolicyObject().apply {
+ statsOutboundDownlink = true
+ statsOutboundUplink = true
+ }
+ }
+
+ inbounds = listOf(
+ InboundObject().apply {
+ tag = "in"
+ this.listen = listen
+ this.port = port
+ protocol = "socks"
+ settings = LazyInboundConfigurationObject(
+ SocksInboundConfigurationObject().apply {
+ auth = "noauth"
+ udp = bean is SOCKSBean && bean.udp
+ userLevel = 0
+ })
+ }
+ )
+
+ outbounds = listOf(
+ OutboundObject().apply {
+ tag = "out"
+ if (bean is SOCKSBean) {
+ protocol = "socks"
+ settings = LazyOutboundConfigurationObject(
+ SocksOutboundConfigurationObject().apply {
+ servers = listOf(
+ SocksOutboundConfigurationObject.ServerObject().apply {
+ address = bean.serverAddress
+ this.port = bean.serverPort
+ users = if (bean.username.isNullOrBlank()) {
+ emptyList()
+ } else {
+ listOf(SocksOutboundConfigurationObject.ServerObject.UserObject()
+ .apply {
+ user = bean.username
+ pass = bean.password
+ level = 0
+ })
+ }
+ }
+ )
+ })
+ }
+ }
+ )
+
+ routing = RoutingObject().apply {
+ domainStrategy = "IPIfNonMatch"
+ rules = listOf(RoutingObject.RuleObject().apply {
+ inboundTag = listOf(
+ "in"
+ )
+ outboundTag = "out"
+ type = "field"
+ })
+ }
+
+ }
+
+ }.let { gson.toJson(it) }
+
+}
+
+fun parseVmessN(link: String): VMessBean {
+ val bean = VMessBean()
+ val json = JSONObject(Base64.decodeStr(link.substringAfter("vmess://")))
+
+ bean.serverAddress = json.getStr("add")
+ bean.serverPort = json.getInt("port")
+ bean.uuid = json.getStr("id")
+ bean.alterId = json.getInt("aid")
+ bean.network = json.getStr("network")
+ bean.header = json.getStr("type")
+ bean.requestHost = json.getStr("host")
+ bean.path = json.getStr("path")
+ bean.name = json.getStr("ps")
+ bean.sni = json.getStr("sni")
+ bean.tls = !json.getStr("tls").isNullOrBlank()
+
+ if (json.getInt("v", 2) < 2) {
+ when (bean.network) {
+ "ws" -> {
+ var path = ""
+ var host = ""
+ val lstParameter = bean.requestHost.split(";")
+ if (lstParameter.isNotEmpty()) {
+ path = lstParameter[0].trim()
+ }
+ if (lstParameter.size > 1) {
+ path = lstParameter[0].trim()
+ host = lstParameter[1].trim()
+ }
+ bean.path = path
+ bean.requestHost = host
+ }
+ "h2" -> {
+ var path = ""
+ var host = ""
+ val lstParameter = bean.requestHost.split(";")
+ if (lstParameter.isNotEmpty()) {
+ path = lstParameter[0].trim()
+ }
+ if (lstParameter.size > 1) {
+ path = lstParameter[0].trim()
+ host = lstParameter[1].trim()
+ }
+ bean.path = path
+ bean.requestHost = host
+ }
+ }
+ }
+
+ bean.initDefaultValues()
+ return bean
+
+}
+
+fun parseVmess1(link: String): VMessBean {
+ val bean = VMessBean()
+ val lnk = link.replace("vmess1://", "https://").toHttpUrl()
+ bean.serverAddress = lnk.host
+ bean.serverPort = lnk.port
+ bean.uuid = lnk.username
+ bean.name = lnk.fragment
+ lnk.queryParameterNames.forEach {
+ when (it) {
+ "tag" -> bean.tag = lnk.queryParameter(it)
+ "tls" -> bean.tls = lnk.queryParameter(it) == "true"
+ "network" -> {
+ bean.network = lnk.queryParameter(it)!!
+ if (bean.network in arrayOf("http", "ws")) {
+ bean.path = lnk.pathSegments.joinToString("/", "/")
+ }
+ }
+ "kcp.uplinkcapacity" -> bean.kcpUpLinkCapacity = lnk.queryParameter(it)!!.toInt()
+ "kcp.downlinkcapacity" -> bean.kcpDownLinkCapacity = lnk.queryParameter(it)!!.toInt()
+ "header" -> bean.header = lnk.queryParameter(it)
+ "mux" -> bean.mux = lnk.queryParameter(it)!!.toInt()
+ // custom
+ "host" -> bean.requestHost = lnk.queryParameter(it)
+ "sni" -> bean.sni = lnk.queryParameter(it)
+ "security" -> bean.security = lnk.queryParameter(it)
+ "alterid" -> bean.alterId = lnk.queryParameter(it)!!.toInt()
+ }
+ }
+
+ bean.initDefaultValues()
+ return bean
+}
+
+fun VMessBean.toVmess1(): String {
+
+ val builder = HttpUrl.Builder()
+ .scheme("https")
+ .host(serverAddress)
+ .port(serverPort)
+
+ if (!uuid.isNullOrBlank()) {
+ builder.username(uuid)
+ }
+
+ if (!path.isNullOrBlank()) {
+ builder.addPathSegment(path)
+ }
+
+ if (!tag.isNullOrBlank()) {
+ builder.addQueryParameter("tag", tag)
+ }
+
+ if (!network.isNullOrBlank()) {
+ builder.addQueryParameter("network", network)
+ }
+
+ if (kcpUpLinkCapacity != 0) {
+ builder.addQueryParameter("kcp.uplinkcapacity", "$kcpUpLinkCapacity")
+ }
+
+ if (kcpDownLinkCapacity != 0) {
+ builder.addQueryParameter("kcp.downlinkcapacity", "$kcpDownLinkCapacity")
+ }
+
+ if (!header.isNullOrBlank()) {
+ builder.addQueryParameter("header", header)
+ }
+
+ if (mux != 0) {
+ builder.addQueryParameter("mux", "$mux")
+ }
+
+ if (!name.isNullOrBlank()) {
+ builder.fragment(name)
+ }
+
+ // custom
+
+ if (!requestHost.isNullOrBlank()) {
+ builder.addQueryParameter("host", requestHost)
+ }
+
+ if (!sni.isNullOrBlank()) {
+ builder.addQueryParameter("sni", sni)
+ }
+
+ if (!security.isNullOrBlank()) {
+ builder.addQueryParameter("security", security)
+ }
+
+ if (alterId != 0) {
+ builder.addQueryParameter("alterid", "$alterId")
+ }
+
+ return builder.build().toString().replace("https://", "vmess1://")
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/nekohasekai/sagernet/ktx/Asyncs.kt b/app/src/main/java/io/nekohasekai/sagernet/ktx/Asyncs.kt
new file mode 100644
index 000000000..528989a9b
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/ktx/Asyncs.kt
@@ -0,0 +1,15 @@
+package io.nekohasekai.sagernet.ktx
+
+import kotlinx.coroutines.*
+
+fun runOnIoDispatcher(block: suspend CoroutineScope.() -> Unit) =
+ GlobalScope.launch(Dispatchers.IO, block = block)
+
+suspend fun onIoDispatcher(block: suspend CoroutineScope.() -> Unit) =
+ withContext(Dispatchers.IO, block = block)
+
+fun runOnMainDispatcher(block: suspend CoroutineScope.() -> Unit) =
+ GlobalScope.launch(Dispatchers.Main, block = block)
+
+suspend fun onMainDispatcher(block: suspend CoroutineScope.() -> Unit) =
+ withContext(Dispatchers.Main, block = block)
\ No newline at end of file
diff --git a/app/src/main/java/io/nekohasekai/sagernet/ktx/Dimens.kt b/app/src/main/java/io/nekohasekai/sagernet/ktx/Dimens.kt
new file mode 100644
index 000000000..1c7a034a7
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/ktx/Dimens.kt
@@ -0,0 +1,14 @@
+package io.nekohasekai.sagernet.ktx
+
+import android.content.res.Resources
+import kotlin.math.ceil
+
+private val density = Resources.getSystem().displayMetrics.density
+
+fun dp2pxf(dpValue: Int): Float {
+ return density * dpValue
+}
+
+fun dp2px(dpValue: Int): Int {
+ return ceil(dp2pxf(dpValue)).toInt()
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/ktx/Kryos.kt b/app/src/main/java/io/nekohasekai/sagernet/ktx/Kryos.kt
new file mode 100644
index 000000000..669aaf9f7
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/ktx/Kryos.kt
@@ -0,0 +1,10 @@
+package io.nekohasekai.sagernet.ktx
+
+import com.esotericsoftware.kryo.io.ByteBufferInput
+import com.esotericsoftware.kryo.io.ByteBufferOutput
+import java.io.InputStream
+import java.io.OutputStream
+
+
+fun InputStream.byteBuffer() = ByteBufferInput(this)
+fun OutputStream.byteBuffer() = ByteBufferOutput(this)
\ No newline at end of file
diff --git a/app/src/main/java/io/nekohasekai/sagernet/ktx/Logs.kt b/app/src/main/java/io/nekohasekai/sagernet/ktx/Logs.kt
new file mode 100644
index 000000000..9b714dd77
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/ktx/Logs.kt
@@ -0,0 +1,66 @@
+package io.nekohasekai.sagernet.ktx
+
+import android.util.Log
+import cn.hutool.core.util.StrUtil
+import io.nekohasekai.sagernet.BuildConfig
+
+object Logs {
+
+ private fun mkTag(): String {
+ val stackTrace = Thread.currentThread().stackTrace
+ return StrUtil.subAfter(stackTrace[4].className, ".", true)
+ }
+
+ fun v(message: String) {
+ if (BuildConfig.DEBUG) {
+ Log.v(mkTag(), message)
+ }
+ }
+
+ fun v(message: String, exception: Throwable) {
+ if (BuildConfig.DEBUG) {
+ Log.v(mkTag(), message, exception)
+ }
+ }
+
+ fun d(message: String) {
+ if (BuildConfig.DEBUG) {
+ Log.d(mkTag(), message)
+ }
+ }
+
+ fun d(message: String, exception: Throwable) {
+ if (BuildConfig.DEBUG) {
+ Log.d(mkTag(), message, exception)
+ }
+ }
+
+ fun i(message: String) {
+ Log.i(mkTag(), message)
+ }
+
+ fun i(message: String, exception: Throwable) {
+ Log.i(mkTag(), message, exception)
+ }
+
+ fun w(message: String) {
+ Log.w(mkTag(), message)
+ }
+
+ fun w(message: String, exception: Throwable) {
+ Log.w(mkTag(), message, exception)
+ }
+
+ fun w(exception: Throwable) {
+ Log.w(mkTag(), exception)
+ }
+
+ fun e(message: String) {
+ Log.e(mkTag(), message)
+ }
+
+ fun e(message: String, exception: Throwable) {
+ Log.e(mkTag(), message, exception)
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/nekohasekai/sagernet/ktx/Preferences.kt b/app/src/main/java/io/nekohasekai/sagernet/ktx/Preferences.kt
new file mode 100644
index 000000000..16bb45c40
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/ktx/Preferences.kt
@@ -0,0 +1,36 @@
+package io.nekohasekai.sagernet.ktx
+
+import androidx.preference.PreferenceDataStore
+import kotlin.reflect.KProperty
+
+fun PreferenceDataStore.string(
+ name: String,
+ defaultValue: () -> String = { "" },
+) = PreferenceProxy(name, defaultValue, ::getString, ::putString)
+
+fun PreferenceDataStore.boolean(
+ name: String,
+ defaultValue: () -> Boolean = { false },
+) = PreferenceProxy(name, defaultValue, ::getBoolean, ::putBoolean)
+
+fun PreferenceDataStore.int(
+ name: String,
+ defaultValue: () -> Int = { 0 },
+) = PreferenceProxy(name, defaultValue, ::getInt, ::putInt)
+
+fun PreferenceDataStore.long(
+ name: String,
+ defaultValue: () -> Long = { 0L },
+) = PreferenceProxy(name, defaultValue, ::getLong, ::putLong)
+
+class PreferenceProxy(
+ val name: String,
+ val defaultValue: () -> T,
+ val getter: (String, T) -> T,
+ val setter: (String, value: T) -> Unit,
+) {
+
+ operator fun setValue(thisObj: Any?, property: KProperty<*>, value: T) = setter(name, value)
+ operator fun getValue(thisObj: Any?, property: KProperty<*>) = getter(name, defaultValue())
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/nekohasekai/sagernet/ktx/Utils.kt b/app/src/main/java/io/nekohasekai/sagernet/ktx/Utils.kt
new file mode 100644
index 000000000..e48d2050d
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/ktx/Utils.kt
@@ -0,0 +1,117 @@
+package io.nekohasekai.sagernet.ktx
+
+import android.annotation.SuppressLint
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.content.pm.PackageInfo
+import android.content.res.Resources
+import android.os.Build
+import android.system.Os
+import android.system.OsConstants
+import android.util.TypedValue
+import androidx.annotation.AttrRes
+import androidx.preference.Preference
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.suspendCancellableCoroutine
+import java.io.FileDescriptor
+import java.net.HttpURLConnection
+import java.net.InetAddress
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
+
+
+inline fun Iterable.forEachTry(action: (T) -> Unit) {
+ var result: Exception? = null
+ for (element in this) try {
+ action(element)
+ } catch (e: Exception) {
+ if (result == null) result = e else result.addSuppressed(e)
+ }
+ if (result != null) {
+ throw result
+ }
+}
+
+val Throwable.readableMessage get() = localizedMessage ?: javaClass.name
+
+/**
+ * https://android.googlesource.com/platform/prebuilts/runtime/+/94fec32/appcompat/hiddenapi-light-greylist.txt#9466
+ */
+private val getInt = FileDescriptor::class.java.getDeclaredMethod("getInt$")
+val FileDescriptor.int get() = getInt.invoke(this) as Int
+
+suspend fun HttpURLConnection.useCancellable(block: suspend HttpURLConnection.() -> T): T {
+ return suspendCancellableCoroutine { cont ->
+ cont.invokeOnCancellation {
+ if (Build.VERSION.SDK_INT >= 26) disconnect() else GlobalScope.launch(Dispatchers.IO) { disconnect() }
+ }
+ GlobalScope.launch(Dispatchers.IO) {
+ try {
+ cont.resume(block())
+ } catch (e: Throwable) {
+ cont.resumeWithException(e)
+ }
+ }
+ }
+}
+
+fun parsePort(str: String?, default: Int, min: Int = 1025): Int {
+ val value = str?.toIntOrNull() ?: default
+ return if (value < min || value > 65535) default else value
+}
+
+fun broadcastReceiver(callback: (Context, Intent) -> Unit): BroadcastReceiver =
+ object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) = callback(context, intent)
+ }
+
+fun Context.listenForPackageChanges(onetime: Boolean = true, callback: () -> Unit) =
+ object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ callback()
+ if (onetime) context.unregisterReceiver(this)
+ }
+ }.apply {
+ registerReceiver(this, IntentFilter().apply {
+ addAction(Intent.ACTION_PACKAGE_ADDED)
+ addAction(Intent.ACTION_PACKAGE_REMOVED)
+ addDataScheme("package")
+ })
+ }
+
+val PackageInfo.signaturesCompat
+ get() =
+ if (Build.VERSION.SDK_INT >= 28) signingInfo.apkContentsSigners else @Suppress("DEPRECATION") signatures
+
+/**
+ * Based on: https://stackoverflow.com/a/26348729/2245107
+ */
+fun Resources.Theme.resolveResourceId(@AttrRes resId: Int): Int {
+ val typedValue = TypedValue()
+ if (!resolveAttribute(resId, typedValue, true)) throw Resources.NotFoundException()
+ return typedValue.resourceId
+}
+
+fun Preference.remove() = parent!!.removePreference(this)
+
+/**
+ * A slightly more performant variant of parseNumericAddress.
+ *
+ * Bug in Android 9.0 and lower: https://issuetracker.google.com/issues/123456213
+ */
+
+private val parseNumericAddress by lazy @SuppressLint("SoonBlockedPrivateApi") {
+ InetAddress::class.java.getDeclaredMethod("parseNumericAddress", String::class.java).apply {
+ isAccessible = true
+ }
+}
+
+fun String?.parseNumericAddress(): InetAddress? = Os.inet_pton(OsConstants.AF_INET, this)
+ ?: Os.inet_pton(OsConstants.AF_INET6, this)?.let {
+ if (Build.VERSION.SDK_INT >= 29) it else parseNumericAddress.invoke(null,
+ this) as InetAddress
+ }
\ No newline at end of file
diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/AboutFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/AboutFragment.kt
new file mode 100644
index 000000000..b19b15a2a
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/ui/AboutFragment.kt
@@ -0,0 +1,24 @@
+package io.nekohasekai.sagernet.ui
+
+import android.content.Context
+import com.danielstone.materialaboutlibrary.MaterialAboutFragment
+import com.danielstone.materialaboutlibrary.model.MaterialAboutCard
+import com.danielstone.materialaboutlibrary.model.MaterialAboutList
+import io.nekohasekai.sagernet.R
+
+class AboutFragment : MaterialAboutFragment() {
+
+ override fun getMaterialAboutList(activityContext: Context?): MaterialAboutList {
+
+ return MaterialAboutList.Builder()
+ .addCard(
+ MaterialAboutCard.Builder()
+ .title(R.string.app_name)
+ .outline(false)
+ .build()
+ )
+ .build()
+
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt
new file mode 100644
index 000000000..dc09aeeb8
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt
@@ -0,0 +1,85 @@
+package io.nekohasekai.sagernet.ui
+
+import android.os.Bundle
+import android.view.Gravity
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.fragment.app.Fragment
+import androidx.viewpager2.widget.ViewPager2
+import com.github.zawadz88.materialpopupmenu.popupMenu
+import com.google.android.material.tabs.TabLayout
+import com.google.android.material.tabs.TabLayoutMediator
+import io.nekohasekai.sagernet.R
+import io.nekohasekai.sagernet.database.SagerDatabase
+import io.nekohasekai.sagernet.ktx.dp2px
+import io.nekohasekai.sagernet.ktx.runOnIoDispatcher
+import io.nekohasekai.sagernet.ui.configuration.GroupPagerAdapter
+
+class ConfigurationFragment : Fragment() {
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
+ ): View? {
+ return inflater.inflate(R.layout.group_list_main, container, false)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ val groupPager = view.findViewById(R.id.group_pager)
+ val tabLayout = view.findViewById(R.id.group_tab)
+ val adapter = GroupPagerAdapter(this)
+ groupPager.adapter = adapter
+
+ TabLayoutMediator(tabLayout, groupPager) { tab, position ->
+ tab.text = adapter.groupList[position].name
+ .takeIf { !it.isNullOrBlank() } ?: getString(R.string.group_default)
+ tab.view.setOnLongClickListener {
+ popupMenu {
+ dropDownVerticalOffset = 100
+ if (position == 0) {
+ dropdownGravity = Gravity.TOP or Gravity.START
+ dropDownHorizontalOffset = dp2px(16)
+ } else if (position == adapter.itemCount - 1) {
+ dropdownGravity = Gravity.TOP or Gravity.END
+ dropDownHorizontalOffset = -dp2px(16)
+ }
+ section {
+ item {
+ icon = R.drawable.ic_action_dns
+ label = "Hello"
+ }
+ item {
+ icon = R.drawable.ic_action_lock
+ label = "Hello W0rld!!!!"
+ }
+ item {
+ icon = R.drawable.ic_action_description
+ label = "1145141919"
+ }
+ val group = adapter.groupList[position]
+ //if (!group.isDefault) {
+ item {
+ icon = R.drawable.ic_action_delete
+ label = "Delete Group"
+ callback = {
+ runOnIoDispatcher {
+ SagerDatabase.groupDao.delete(group)
+ adapter.reloadList()
+ }
+ }
+ }
+ // }
+ }
+ }.show(requireContext(), it)
+
+ true
+ }
+ }.attach()
+
+ adapter.reloadList()
+
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/MainActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/MainActivity.kt
new file mode 100644
index 000000000..1272abd3f
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/ui/MainActivity.kt
@@ -0,0 +1,182 @@
+package io.nekohasekai.sagernet.ui
+
+import android.os.Bundle
+import android.os.RemoteException
+import android.view.ViewGroup
+import androidx.appcompat.app.AppCompatActivity
+import androidx.appcompat.widget.Toolbar
+import androidx.coordinatorlayout.widget.CoordinatorLayout
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.updateLayoutParams
+import androidx.drawerlayout.widget.DrawerLayout
+import androidx.navigation.findNavController
+import androidx.navigation.ui.AppBarConfiguration
+import androidx.navigation.ui.navigateUp
+import androidx.navigation.ui.setupActionBarWithNavController
+import androidx.navigation.ui.setupWithNavController
+import androidx.preference.PreferenceDataStore
+import com.github.shadowsocks.aidl.IShadowsocksService
+import com.github.shadowsocks.aidl.TrafficStats
+import com.google.android.material.appbar.AppBarLayout
+import com.google.android.material.navigation.NavigationView
+import com.google.android.material.snackbar.Snackbar
+import io.nekohasekai.sagernet.Key
+import io.nekohasekai.sagernet.R
+import io.nekohasekai.sagernet.SagerApp
+import io.nekohasekai.sagernet.bg.BaseService
+import io.nekohasekai.sagernet.bg.SagerConnection
+import io.nekohasekai.sagernet.database.DataStore
+import io.nekohasekai.sagernet.database.preference.OnPreferenceDataStoreChangeListener
+import io.nekohasekai.sagernet.ktx.dp2pxf
+import io.nekohasekai.sagernet.widget.ListHolderListener
+import io.nekohasekai.sagernet.widget.ServiceButton
+import io.nekohasekai.sagernet.widget.StatsBar
+
+
+class MainActivity : AppCompatActivity(), SagerConnection.Callback,
+ OnPreferenceDataStoreChangeListener {
+
+ private lateinit var appBarConfiguration: AppBarConfiguration
+ lateinit var fab: ServiceButton
+ lateinit var stats: StatsBar
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_main)
+ val toolbar: Toolbar = findViewById(R.id.toolbar)
+ setSupportActionBar(toolbar)
+
+ snackbar = findViewById(R.id.snackbar)
+ ViewCompat.setOnApplyWindowInsetsListener(snackbar, ListHolderListener)
+
+ val appBarLayout: AppBarLayout = findViewById(R.id.appbar)
+ val elevation = dp2pxf(4)
+
+ fab = findViewById(R.id.fab)
+ stats = findViewById(R.id.stats)
+
+ val drawerLayout: DrawerLayout = findViewById(R.id.drawer_layout)
+ val navView: NavigationView = findViewById(R.id.nav_view)
+ val navController = findNavController(R.id.nav_host_fragment)
+
+ appBarConfiguration = AppBarConfiguration(setOf(
+ R.id.nav_configuration, R.id.nav_about
+ ), drawerLayout)
+
+ setupActionBarWithNavController(navController, appBarConfiguration)
+ navView.setupWithNavController(navController)
+
+ navController.addOnDestinationChangedListener { _, destination, _ ->
+ appBarLayout.elevation = if (destination.id == R.id.nav_configuration) 0f else elevation
+ }
+
+ ViewCompat.setOnApplyWindowInsetsListener(fab) { view, insets ->
+ view.updateLayoutParams {
+ bottomMargin = insets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom +
+ resources.getDimensionPixelOffset(R.dimen.mtrl_bottomappbar_fab_bottom_margin)
+ }
+ insets
+ }
+
+ fab.setOnClickListener { toggle() }
+ stats.setOnClickListener { if (state == BaseService.State.Connected) stats.testConnection() }
+
+ changeState(BaseService.State.Idle) // reset everything to init state
+
+ connection.connect(this, this)
+ DataStore.publicStore.registerChangeListener(this)
+ }
+
+
+ var state = BaseService.State.Idle
+
+ private fun changeState(
+ state: BaseService.State,
+ msg: String? = null,
+ animate: Boolean = false,
+ ) {
+ fab.changeState(state, this.state, animate)
+ stats.changeState(state)
+ if (msg != null) snackbar(getString(R.string.vpn_error, msg)).show()
+ this.state = state
+ /* ProfilesFragment.instance?.profilesAdapter?.notifyDataSetChanged() // refresh button enabled state
+ stateListener?.invoke(state)*/
+ }
+
+ lateinit var snackbar: CoordinatorLayout private set
+ fun snackbar(text: CharSequence = "") =
+ Snackbar.make(snackbar, text, Snackbar.LENGTH_LONG).apply {
+ anchorView = fab
+ }
+
+
+ override fun stateChanged(state: BaseService.State, profileName: String?, msg: String?) {
+ changeState(state, msg, true)
+ }
+
+ private fun toggle() {
+ if (state.canStop) SagerApp.stopService() else connect.launch(null)
+ }
+
+ private val connection = SagerConnection(true)
+ override fun onServiceConnected(service: IShadowsocksService) = changeState(try {
+ BaseService.State.values()[service.state]
+ } catch (_: RemoteException) {
+ BaseService.State.Idle
+ })
+
+ override fun onServiceDisconnected() = changeState(BaseService.State.Idle)
+ override fun onBinderDied() {
+ connection.disconnect(this)
+ connection.connect(this, this)
+ }
+
+ private val connect = registerForActivityResult(VpnRequestActivity.StartService()) {
+ if (it) snackbar().setText(R.string.vpn_permission_denied).show()
+ }
+
+ override fun trafficUpdated(profileId: Long, stats: TrafficStats) {
+ if (profileId == 0L) this@MainActivity.stats.updateTraffic(
+ stats.txRate, stats.rxRate, stats.txTotal, stats.rxTotal)
+ if (state != BaseService.State.Stopping) {
+ (supportFragmentManager.findFragmentById(R.id.nav_configuration) as? ConfigurationFragment)
+// ?.onTrafficUpdated(profileId, stats)
+ }
+ }
+
+ override fun trafficPersisted(profileId: Long) {
+// ProfilesFragment.instance?.onTrafficPersisted(profileId)
+ }
+
+ override fun onPreferenceDataStoreChanged(store: PreferenceDataStore, key: String) {
+ when (key) {
+ Key.SERVICE_MODE -> {
+ connection.disconnect(this)
+ connection.connect(this, this)
+ }
+ }
+ }
+
+ override fun onStart() {
+ super.onStart()
+ connection.bandwidthTimeout = 1000
+ }
+
+ override fun onStop() {
+ connection.bandwidthTimeout = 0
+ super.onStop()
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ DataStore.publicStore.unregisterChangeListener(this)
+ connection.disconnect(this)
+ }
+
+ override fun onSupportNavigateUp(): Boolean {
+ val navController = findNavController(R.id.nav_host_fragment)
+ return navController.navigateUp(appBarConfiguration) || super.onSupportNavigateUp()
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/VpnRequestActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/VpnRequestActivity.kt
new file mode 100644
index 000000000..c27db4ddb
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/ui/VpnRequestActivity.kt
@@ -0,0 +1,71 @@
+package io.nekohasekai.sagernet.ui
+
+import android.app.Activity
+import android.app.KeyguardManager
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.net.VpnService
+import android.os.Bundle
+import android.widget.Toast
+import androidx.activity.result.contract.ActivityResultContract
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.content.getSystemService
+import io.nekohasekai.sagernet.Key
+import io.nekohasekai.sagernet.R
+import io.nekohasekai.sagernet.SagerApp
+import io.nekohasekai.sagernet.database.DataStore
+import io.nekohasekai.sagernet.ktx.broadcastReceiver
+
+class VpnRequestActivity : AppCompatActivity() {
+ private var receiver: BroadcastReceiver? = null
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ if (getSystemService()!!.isKeyguardLocked) {
+ receiver = broadcastReceiver { _, _ -> connect.launch(null) }
+ registerReceiver(receiver, IntentFilter(Intent.ACTION_USER_PRESENT))
+ } else connect.launch(null)
+ }
+
+ private val connect = registerForActivityResult(StartService()) {
+ if (it) Toast.makeText(this, R.string.vpn_permission_denied, Toast.LENGTH_LONG).show()
+ finish()
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ if (receiver != null) unregisterReceiver(receiver)
+ }
+
+ class StartService : ActivityResultContract() {
+ private var cachedIntent: Intent? = null
+
+ override fun getSynchronousResult(
+ context: Context,
+ input: Void?,
+ ): SynchronousResult? {
+ if (DataStore.serviceMode == Key.MODE_VPN) VpnService.prepare(context)?.let { intent ->
+ cachedIntent = intent
+ return null
+ }
+ SagerApp.startService()
+ return SynchronousResult(false)
+ }
+
+ override fun createIntent(context: Context, input: Void?) =
+ cachedIntent!!.also { cachedIntent = null }
+
+ override fun parseResult(resultCode: Int, intent: Intent?) =
+ if (resultCode == Activity.RESULT_OK) {
+ SagerApp.startService()
+ false
+ } else {
+// Timber.e("Failed to start VpnService: $intent")
+ true
+ }
+ }
+
+
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/configuration/ConfigurationAdapter.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/configuration/ConfigurationAdapter.kt
new file mode 100644
index 000000000..59298fe47
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/ui/configuration/ConfigurationAdapter.kt
@@ -0,0 +1,60 @@
+package io.nekohasekai.sagernet.ui.configuration
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.RecyclerView
+import io.nekohasekai.sagernet.R
+import io.nekohasekai.sagernet.database.ProxyEntity
+import io.nekohasekai.sagernet.database.SagerDatabase
+import io.nekohasekai.sagernet.fmt.socks.SOCKSBean
+import io.nekohasekai.sagernet.ktx.onMainDispatcher
+import io.nekohasekai.sagernet.ktx.runOnIoDispatcher
+
+class ConfigurationAdapter(private val groupIdToQuery: Long) :
+ RecyclerView.Adapter() {
+
+ var configurationList: List = listOf()
+
+ fun reloadList() {
+ runOnIoDispatcher {
+ configurationList = SagerDatabase.proxyDao.getByGroup(groupIdToQuery)
+ if (configurationList.isEmpty() &&
+ (SagerDatabase.groupDao.getById(groupIdToQuery)
+ ?: return@runOnIoDispatcher).isDefault
+ ) {
+ SagerDatabase.proxyDao.addProxy(ProxyEntity(
+ groupId = groupIdToQuery,
+ type = "socks",
+ socksBean = SOCKSBean().apply {
+ serverAddress = "127.0.0.1"
+ serverPort = 1080
+ name = "Hello W0rld!"
+ }
+ ))
+ configurationList = SagerDatabase.proxyDao.getByGroup(groupIdToQuery)
+ }
+ onMainDispatcher {
+ notifyDataSetChanged()
+ }
+ }
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ConfigurationHolder {
+ return ConfigurationHolder(
+ LayoutInflater.from(parent.context).inflate(R.layout.layout_profile, parent, false)
+ )
+ }
+
+ override fun getItemId(position: Int): Long {
+ return configurationList[position].id
+ }
+
+ override fun onBindViewHolder(holder: ConfigurationHolder, position: Int) {
+ holder.bind(configurationList[position])
+ }
+
+ override fun getItemCount(): Int {
+ return configurationList.size
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/configuration/ConfigurationHolder.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/configuration/ConfigurationHolder.kt
new file mode 100644
index 000000000..2c3d7c24e
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/ui/configuration/ConfigurationHolder.kt
@@ -0,0 +1,68 @@
+package io.nekohasekai.sagernet.ui.configuration
+
+import android.content.Intent
+import android.text.format.Formatter
+import android.view.View
+import android.widget.ImageView
+import android.widget.LinearLayout
+import android.widget.TextView
+import androidx.core.view.isGone
+import androidx.recyclerview.widget.RecyclerView
+import io.nekohasekai.sagernet.R
+import io.nekohasekai.sagernet.database.DataStore
+import io.nekohasekai.sagernet.database.ProxyEntity
+import io.nekohasekai.sagernet.ktx.onMainDispatcher
+import io.nekohasekai.sagernet.ktx.runOnIoDispatcher
+import io.nekohasekai.sagernet.ui.settings.SettingsActivity
+
+class ConfigurationHolder(val view: View) : RecyclerView.ViewHolder(view) {
+
+ val profileName: TextView = view.findViewById(R.id.profile_name)
+ val profileType: TextView = view.findViewById(R.id.profile_type)
+ val trafficText: TextView = view.findViewById(R.id.traffic_text)
+ val selectedView: LinearLayout = view.findViewById(R.id.selected_view)
+ val editButton: ImageView = view.findViewById(R.id.edit)
+
+ fun bind(proxyEntity: ProxyEntity) {
+ view.setOnClickListener {
+ runOnIoDispatcher {
+ if (DataStore.selectedProxy != proxyEntity.id) {
+ DataStore.selectedProxy = proxyEntity.id
+ onMainDispatcher {
+ bind(proxyEntity)
+ }
+ }
+ }
+ }
+
+ profileName.text = proxyEntity.requireBean().name
+ profileType.text = proxyEntity.displayType()
+ val showTraffic = proxyEntity.rx + proxyEntity.tx != 0L
+ trafficText.isGone = !showTraffic
+ if (showTraffic) {
+ trafficText.text = view.context.getString(R.string.traffic,
+ Formatter.formatFileSize(view.context, proxyEntity.rx),
+ Formatter.formatFileSize(view.context, proxyEntity.tx))
+ }
+
+ editButton.setOnClickListener {
+ it.context.startActivity(Intent(it.context, SettingsActivity::class.java).apply {
+ putExtra("id", proxyEntity.id)
+ })
+ }
+
+ runOnIoDispatcher {
+ if (DataStore.selectedProxy == proxyEntity.id) {
+ onMainDispatcher {
+ selectedView.visibility = View.VISIBLE
+ }
+ } else {
+ onMainDispatcher {
+ selectedView.visibility = View.INVISIBLE
+ }
+ }
+ }
+
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/configuration/GroupFragment.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/configuration/GroupFragment.kt
new file mode 100644
index 000000000..9de6a0c7e
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/ui/configuration/GroupFragment.kt
@@ -0,0 +1,39 @@
+package io.nekohasekai.sagernet.ui.configuration
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.fragment.app.Fragment
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import io.nekohasekai.sagernet.R
+import io.nekohasekai.sagernet.database.ProxyGroup
+
+class GroupFragment @JvmOverloads constructor(private val proxyGroup: ProxyGroup? = null) :
+ Fragment() {
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
+ ): View? {
+ return inflater.inflate(R.layout.configurtion_list_main, container, false)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ val configurationList = view.findViewById(R.id.configuration_list)
+ if (proxyGroup == null) return
+
+ val adapter = ConfigurationAdapter(proxyGroup.id)
+
+ configurationList.layoutManager = when (proxyGroup.layout) {
+ else -> LinearLayoutManager(view.context)
+ }
+ configurationList.adapter = adapter
+
+ adapter.reloadList()
+
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/configuration/GroupPagerAdapter.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/configuration/GroupPagerAdapter.kt
new file mode 100644
index 000000000..a587e01a5
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/ui/configuration/GroupPagerAdapter.kt
@@ -0,0 +1,45 @@
+package io.nekohasekai.sagernet.ui.configuration
+
+import androidx.fragment.app.Fragment
+import androidx.viewpager2.adapter.FragmentStateAdapter
+import io.nekohasekai.sagernet.database.ProxyGroup
+import io.nekohasekai.sagernet.database.SagerDatabase
+import io.nekohasekai.sagernet.ktx.onMainDispatcher
+import io.nekohasekai.sagernet.ktx.runOnIoDispatcher
+
+class GroupPagerAdapter(
+ activity: Fragment,
+) : FragmentStateAdapter(activity) {
+
+ var groupList: List = listOf()
+
+ fun reloadList() {
+ runOnIoDispatcher {
+ groupList = SagerDatabase.groupDao.allGroups()
+ if (groupList.isEmpty()) {
+ SagerDatabase.groupDao.createGroup(ProxyGroup(isDefault = true))
+ groupList = SagerDatabase.groupDao.allGroups()
+ }
+ onMainDispatcher {
+ notifyDataSetChanged()
+ }
+ }
+ }
+
+ override fun getItemCount(): Int {
+ return groupList.size
+ }
+
+ override fun createFragment(position: Int): Fragment {
+ return GroupFragment(groupList[position])
+ }
+
+ override fun getItemId(position: Int): Long {
+ return groupList[position].id
+ }
+
+ override fun containsItem(itemId: Long): Boolean {
+ return groupList.any { it.id == itemId }
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/nekohasekai/sagernet/ui/settings/SettingsActivity.kt b/app/src/main/java/io/nekohasekai/sagernet/ui/settings/SettingsActivity.kt
new file mode 100644
index 000000000..627abb45e
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/ui/settings/SettingsActivity.kt
@@ -0,0 +1,44 @@
+package io.nekohasekai.sagernet.ui.settings
+
+import android.os.Bundle
+import androidx.appcompat.app.AppCompatActivity
+import androidx.preference.PreferenceFragmentCompat
+import io.nekohasekai.sagernet.R
+import io.nekohasekai.sagernet.database.SagerDatabase
+import io.nekohasekai.sagernet.ktx.onMainDispatcher
+import io.nekohasekai.sagernet.ktx.runOnIoDispatcher
+
+class SettingsActivity : AppCompatActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.settings_activity)
+ if (savedInstanceState == null) {
+ val entityId = intent.getLongExtra("id", -1)
+ if (entityId == -1L) {
+ finish()
+ return
+ }
+ runOnIoDispatcher {
+ val entity = SagerDatabase.proxyDao.getById(entityId)
+ if (entity == null) {
+ onMainDispatcher {
+ finish()
+ }
+ }
+
+ }
+ supportFragmentManager
+ .beginTransaction()
+ .replace(R.id.settings, SettingsFragment())
+ .commit()
+ }
+ supportActionBar?.setDisplayHomeAsUpEnabled(true)
+ }
+
+ class SettingsFragment : PreferenceFragmentCompat() {
+ override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
+ setPreferencesFromResource(R.xml.root_preferences, rootKey)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/nekohasekai/sagernet/utils/Commandline.kt b/app/src/main/java/io/nekohasekai/sagernet/utils/Commandline.kt
new file mode 100644
index 000000000..03c32f6e5
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/utils/Commandline.kt
@@ -0,0 +1,140 @@
+package io.nekohasekai.sagernet.utils
+
+import java.util.*
+
+/**
+ * Commandline objects help handling command lines specifying processes to
+ * execute.
+ *
+ * The class can be used to define a command line as nested elements or as a
+ * helper to define a command line by an application.
+ *
+ *
+ * `
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+` *
+ *
+ * Based on: https://github.com/apache/ant/blob/588ce1f/src/main/org/apache/tools/ant/types/Commandline.java
+ *
+ * Adds support for escape character '\'.
+ */
+object Commandline {
+
+ /**
+ * Quote the parts of the given array in way that makes them
+ * usable as command line arguments.
+ * @param args the list of arguments to quote.
+ * @return empty string for null or no command, else every argument split
+ * by spaces and quoted by quoting rules.
+ */
+ fun toString(args: Iterable?): String {
+ // empty path return empty string
+ args ?: return ""
+ // path containing one or more elements
+ val result = StringBuilder()
+ for (arg in args) {
+ if (result.isNotEmpty()) result.append(' ')
+ arg.indices.map { arg[it] }.forEach {
+ when (it) {
+ ' ', '\\', '"', '\'' -> {
+ result.append('\\') // intentionally no break
+ result.append(it)
+ }
+ else -> result.append(it)
+ }
+ }
+ }
+ return result.toString()
+ }
+
+ /**
+ * Quote the parts of the given array in way that makes them
+ * usable as command line arguments.
+ * @param args the list of arguments to quote.
+ * @return empty string for null or no command, else every argument split
+ * by spaces and quoted by quoting rules.
+ */
+ fun toString(args: Array) =
+ toString(args.asIterable()) // thanks to Java, arrays aren't iterable
+
+ /**
+ * Crack a command line.
+ * @param toProcess the command line to process.
+ * @return the command line broken into strings.
+ * An empty or null toProcess parameter results in a zero sized array.
+ */
+ fun translateCommandline(toProcess: String?): Array {
+ if (toProcess == null || toProcess.isEmpty()) {
+ //no command? no string
+ return arrayOf()
+ }
+ // parse with a simple finite state machine
+
+ val normal = 0
+ val inQuote = 1
+ val inDoubleQuote = 2
+ var state = normal
+ val tok = StringTokenizer(toProcess, "\\\"\' ", true)
+ val result = ArrayList()
+ val current = StringBuilder()
+ var lastTokenHasBeenQuoted = false
+ var lastTokenIsSlash = false
+
+ while (tok.hasMoreTokens()) {
+ val nextTok = tok.nextToken()
+ when (state) {
+ inQuote -> if ("\'" == nextTok) {
+ lastTokenHasBeenQuoted = true
+ state = normal
+ } else current.append(nextTok)
+ inDoubleQuote -> when (nextTok) {
+ "\"" -> if (lastTokenIsSlash) {
+ current.append(nextTok)
+ lastTokenIsSlash = false
+ } else {
+ lastTokenHasBeenQuoted = true
+ state = normal
+ }
+ "\\" -> lastTokenIsSlash = if (lastTokenIsSlash) {
+ current.append(nextTok)
+ false
+ } else true
+ else -> {
+ if (lastTokenIsSlash) {
+ current.append("\\") // unescaped
+ lastTokenIsSlash = false
+ }
+ current.append(nextTok)
+ }
+ }
+ else -> {
+ when {
+ lastTokenIsSlash -> {
+ current.append(nextTok)
+ lastTokenIsSlash = false
+ }
+ "\\" == nextTok -> lastTokenIsSlash = true
+ "\'" == nextTok -> state = inQuote
+ "\"" == nextTok -> state = inDoubleQuote
+ " " == nextTok -> if (lastTokenHasBeenQuoted || current.isNotEmpty()) {
+ result.add(current.toString())
+ current.setLength(0)
+ }
+ else -> current.append(nextTok)
+ }
+ lastTokenHasBeenQuoted = false
+ }
+ }
+ }
+ if (lastTokenHasBeenQuoted || current.isNotEmpty()) result.add(current.toString())
+ require(state != inQuote && state != inDoubleQuote) { "unbalanced quotes in $toProcess" }
+ require(!lastTokenIsSlash) { "escape character following nothing in $toProcess" }
+ return result.toTypedArray()
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/utils/DeviceStorageApp.kt b/app/src/main/java/io/nekohasekai/sagernet/utils/DeviceStorageApp.kt
new file mode 100644
index 000000000..fc4f63244
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/utils/DeviceStorageApp.kt
@@ -0,0 +1,20 @@
+package io.nekohasekai.sagernet.utils
+
+import android.annotation.SuppressLint
+import android.annotation.TargetApi
+import android.app.Application
+import android.content.Context
+
+@SuppressLint("Registered")
+@TargetApi(24)
+class DeviceStorageApp(context: Context) : Application() {
+ init {
+ attachBaseContext(context.createDeviceProtectedStorageContext())
+ }
+
+ /**
+ * Thou shalt not get the REAL underlying application context which would no longer be operating under device
+ * protected storage.
+ */
+ override fun getApplicationContext() = this
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/utils/Subnet.kt b/app/src/main/java/io/nekohasekai/sagernet/utils/Subnet.kt
new file mode 100644
index 000000000..80af40aa5
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/utils/Subnet.kt
@@ -0,0 +1,86 @@
+package io.nekohasekai.sagernet.utils
+
+import io.nekohasekai.sagernet.ktx.parseNumericAddress
+import java.net.InetAddress
+import java.util.*
+
+class Subnet(val address: InetAddress, val prefixSize: Int) : Comparable {
+ companion object {
+ fun fromString(value: String, lengthCheck: Int = -1): Subnet? {
+ val parts = value.split('/', limit = 2)
+ val addr = parts[0].parseNumericAddress() ?: return null
+ check(lengthCheck < 0 || addr.address.size == lengthCheck)
+ return if (parts.size == 2) try {
+ val prefixSize = parts[1].toInt()
+ if (prefixSize < 0 || prefixSize > addr.address.size shl 3) null else Subnet(addr,
+ prefixSize)
+ } catch (_: NumberFormatException) {
+ null
+ } else Subnet(addr, addr.address.size shl 3)
+ }
+ }
+
+ private val addressLength get() = address.address.size shl 3
+
+ init {
+ require(prefixSize in 0..addressLength) { "prefixSize $prefixSize not in 0..$addressLength" }
+ }
+
+ class Immutable(private val a: ByteArray, private val prefixSize: Int = 0) {
+ companion object : Comparator {
+ override fun compare(a: Immutable, b: Immutable): Int {
+ check(a.a.size == b.a.size)
+ for (i in a.a.indices) {
+ val result = a.a[i].compareTo(b.a[i])
+ if (result != 0) return result
+ }
+ return 0
+ }
+ }
+
+ fun matches(b: Immutable) = matches(b.a)
+ fun matches(b: ByteArray): Boolean {
+ if (a.size != b.size) return false
+ var i = 0
+ while (i * 8 < prefixSize && i * 8 + 8 <= prefixSize) {
+ if (a[i] != b[i]) return false
+ ++i
+ }
+ return i * 8 == prefixSize || a[i] == (b[i].toInt() and -(1 shl i * 8 + 8 - prefixSize)).toByte()
+ }
+ }
+
+ fun toImmutable() = Immutable(address.address.also {
+ var i = prefixSize / 8
+ if (prefixSize % 8 > 0) {
+ it[i] = (it[i].toInt() and -(1 shl i * 8 + 8 - prefixSize)).toByte()
+ ++i
+ }
+ while (i < it.size) it[i++] = 0
+ }, prefixSize)
+
+ override fun toString(): String =
+ if (prefixSize == addressLength) address.hostAddress else address.hostAddress + '/' + prefixSize
+
+ private fun Byte.unsigned() = toInt() and 0xFF
+ override fun compareTo(other: Subnet): Int {
+ val addrThis = address.address
+ val addrThat = other.address.address
+ var result =
+ addrThis.size.compareTo(addrThat.size) // IPv4 address goes first
+ if (result != 0) return result
+ for (i in addrThis.indices) {
+ result = addrThis[i].unsigned()
+ .compareTo(addrThat[i].unsigned()) // undo sign extension of signed byte
+ if (result != 0) return result
+ }
+ return prefixSize.compareTo(other.prefixSize)
+ }
+
+ override fun equals(other: Any?): Boolean {
+ val that = other as? Subnet
+ return address == that?.address && prefixSize == that.prefixSize
+ }
+
+ override fun hashCode(): Int = Objects.hash(address, prefixSize)
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/widget/AutoCollapseTextView.kt b/app/src/main/java/io/nekohasekai/sagernet/widget/AutoCollapseTextView.kt
new file mode 100644
index 000000000..0d841fc75
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/widget/AutoCollapseTextView.kt
@@ -0,0 +1,57 @@
+/*******************************************************************************
+ * *
+ * Copyright (C) 2018 by Max Lv *
+ * Copyright (C) 2018 by Mygod Studio *
+ * *
+ * 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 . *
+ * *
+ *******************************************************************************/
+
+package io.nekohasekai.sagernet.widget
+
+import android.content.Context
+import android.graphics.Rect
+import android.util.AttributeSet
+import android.view.MotionEvent
+import androidx.appcompat.widget.AppCompatTextView
+import androidx.core.view.isGone
+
+class AutoCollapseTextView @JvmOverloads constructor(
+ context: Context, attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0,
+) :
+ AppCompatTextView(context, attrs, defStyleAttr) {
+ override fun onTextChanged(
+ text: CharSequence?,
+ start: Int,
+ lengthBefore: Int,
+ lengthAfter: Int,
+ ) {
+ super.onTextChanged(text, start, lengthBefore, lengthAfter)
+ isGone = text.isNullOrEmpty()
+ }
+
+ // #1874
+ override fun onFocusChanged(focused: Boolean, direction: Int, previouslyFocusedRect: Rect?) =
+ try {
+ super.onFocusChanged(focused, direction, previouslyFocusedRect)
+ } catch (e: IndexOutOfBoundsException) {
+ }
+
+ override fun onTouchEvent(event: MotionEvent?) = try {
+ super.onTouchEvent(event)
+ } catch (e: IndexOutOfBoundsException) {
+ false
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/widget/ServiceButton.kt b/app/src/main/java/io/nekohasekai/sagernet/widget/ServiceButton.kt
new file mode 100644
index 000000000..349d609a2
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/widget/ServiceButton.kt
@@ -0,0 +1,100 @@
+package io.nekohasekai.sagernet.widget
+
+import android.content.Context
+import android.graphics.drawable.Drawable
+import android.os.Build
+import android.util.AttributeSet
+import android.view.PointerIcon
+import android.view.View
+import androidx.annotation.DrawableRes
+import androidx.appcompat.widget.TooltipCompat
+import androidx.vectordrawable.graphics.drawable.Animatable2Compat
+import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat
+import com.google.android.material.floatingactionbutton.FloatingActionButton
+import io.nekohasekai.sagernet.R
+import io.nekohasekai.sagernet.bg.BaseService
+import java.util.*
+
+class ServiceButton @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0,
+) :
+ FloatingActionButton(context, attrs, defStyleAttr) {
+ private val callback = object : Animatable2Compat.AnimationCallback() {
+ override fun onAnimationEnd(drawable: Drawable) {
+ super.onAnimationEnd(drawable)
+ var next = animationQueue.peek() ?: return
+ if (next.current == drawable) {
+ animationQueue.pop()
+ next = animationQueue.peek() ?: return
+ }
+ setImageDrawable(next)
+ next.start()
+ }
+ }
+
+ private fun createIcon(@DrawableRes resId: Int): AnimatedVectorDrawableCompat {
+ val result = AnimatedVectorDrawableCompat.create(context, resId)!!
+ result.registerAnimationCallback(callback)
+ return result
+ }
+
+ private val iconStopped by lazy { createIcon(R.drawable.ic_service_stopped) }
+ private val iconConnecting by lazy { createIcon(R.drawable.ic_service_connecting) }
+ private val iconConnected by lazy { createIcon(R.drawable.ic_service_connected) }
+ private val iconStopping by lazy { createIcon(R.drawable.ic_service_stopping) }
+ private val animationQueue = ArrayDeque()
+
+ private var checked = false
+
+ override fun onCreateDrawableState(extraSpace: Int): IntArray {
+ val drawableState = super.onCreateDrawableState(extraSpace + 1)
+ if (checked) View.mergeDrawableStates(drawableState,
+ intArrayOf(android.R.attr.state_checked))
+ return drawableState
+ }
+
+ fun changeState(state: BaseService.State, previousState: BaseService.State, animate: Boolean) {
+ when (state) {
+ BaseService.State.Connecting -> changeState(iconConnecting, animate)
+ BaseService.State.Connected -> changeState(iconConnected, animate)
+ BaseService.State.Stopping -> {
+ changeState(iconStopping, animate && previousState == BaseService.State.Connected)
+ }
+ else -> changeState(iconStopped, animate)
+ }
+ checked = state == BaseService.State.Connected
+ refreshDrawableState()
+ val description = context.getText(if (state.canStop) R.string.stop else R.string.connect)
+ contentDescription = description
+ TooltipCompat.setTooltipText(this, description)
+ val enabled = state.canStop || state == BaseService.State.Stopped
+ isEnabled = enabled
+ if (Build.VERSION.SDK_INT >= 24) pointerIcon = PointerIcon.getSystemIcon(context,
+ if (enabled) PointerIcon.TYPE_HAND else PointerIcon.TYPE_WAIT)
+ }
+
+ private fun changeState(icon: AnimatedVectorDrawableCompat, animate: Boolean) {
+ fun counters(a: AnimatedVectorDrawableCompat, b: AnimatedVectorDrawableCompat): Boolean =
+ a == iconStopped && b == iconConnecting ||
+ a == iconConnecting && b == iconStopped ||
+ a == iconConnected && b == iconStopping ||
+ a == iconStopping && b == iconConnected
+ if (animate) {
+ if (animationQueue.size < 2 || !counters(animationQueue.last, icon)) {
+ animationQueue.add(icon)
+ if (animationQueue.size == 1) {
+ setImageDrawable(icon)
+ icon.start()
+ }
+ } else animationQueue.removeLast()
+ } else {
+ animationQueue.peekFirst()?.stop()
+ animationQueue.clear()
+ setImageDrawable(icon)
+ icon.start() // force ensureAnimatorSet to be called so that stop() will work
+ icon.stop()
+ }
+ }
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/widget/StatsBar.kt b/app/src/main/java/io/nekohasekai/sagernet/widget/StatsBar.kt
new file mode 100644
index 000000000..36c02bb03
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/widget/StatsBar.kt
@@ -0,0 +1,121 @@
+/*******************************************************************************
+ * *
+ * Copyright (C) 2018 by Max Lv *
+ * Copyright (C) 2018 by Mygod Studio *
+ * *
+ * 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 . *
+ * *
+ *******************************************************************************/
+
+package io.nekohasekai.sagernet.widget
+
+import android.content.Context
+import android.text.format.Formatter
+import android.util.AttributeSet
+import android.view.View
+import android.widget.TextView
+import androidx.appcompat.widget.TooltipCompat
+import androidx.coordinatorlayout.widget.CoordinatorLayout
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.whenStarted
+import com.google.android.material.bottomappbar.BottomAppBar
+import io.nekohasekai.sagernet.R
+import io.nekohasekai.sagernet.bg.BaseService
+import io.nekohasekai.sagernet.ui.MainActivity
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+
+class StatsBar @JvmOverloads constructor(
+ context: Context, attrs: AttributeSet? = null,
+ defStyleAttr: Int = R.attr.bottomAppBarStyle,
+) :
+ BottomAppBar(context, attrs, defStyleAttr) {
+ private lateinit var statusText: TextView
+ private lateinit var txText: TextView
+ private lateinit var rxText: TextView
+ private lateinit var behavior: Behavior
+ override fun getBehavior(): Behavior {
+ if (!this::behavior.isInitialized) behavior = object : Behavior() {
+ override fun onNestedScroll(
+ coordinatorLayout: CoordinatorLayout, child: BottomAppBar, target: View,
+ dxConsumed: Int, dyConsumed: Int, dxUnconsumed: Int, dyUnconsumed: Int,
+ type: Int, consumed: IntArray,
+ ) {
+ super.onNestedScroll(coordinatorLayout,
+ child,
+ target,
+ dxConsumed,
+ dyConsumed + dyUnconsumed,
+ dxUnconsumed,
+ 0,
+ type,
+ consumed)
+ }
+ }
+ return behavior
+ }
+
+ override fun setOnClickListener(l: OnClickListener?) {
+ statusText = findViewById(R.id.status)
+ txText = findViewById(R.id.tx)
+ rxText = findViewById(R.id.rx)
+ super.setOnClickListener(l)
+ }
+
+ private fun setStatus(text: CharSequence) {
+ statusText.text = text
+ TooltipCompat.setTooltipText(this, text)
+ }
+
+ fun changeState(state: BaseService.State) {
+ val activity = context as MainActivity
+ fun postWhenStarted(what: () -> Unit) = activity.lifecycleScope.launch(Dispatchers.Main) {
+ activity.whenStarted { what() }
+ }
+ if ((state == BaseService.State.Connected).also { hideOnScroll = it }) {
+ postWhenStarted { performShow() }
+ /* tester.status.observe(activity) {
+ it.retrieve(this::setStatus) { msg ->
+ activity.snackbar(msg).show()
+ }
+ }*/
+ } else {
+ postWhenStarted { performHide() }
+ updateTraffic(0, 0, 0, 0)
+ /* tester.status.removeObservers(activity)
+ if (state != BaseService.State.Idle) tester.invalidate()*/
+ setStatus(context.getText(when (state) {
+ BaseService.State.Connecting -> R.string.connecting
+ BaseService.State.Stopping -> R.string.stopping
+ else -> R.string.not_connected
+ }))
+ }
+ }
+
+ fun updateTraffic(txRate: Long, rxRate: Long, txTotal: Long, rxTotal: Long) {
+ txText.text = "▲ ${Formatter.formatFileSize(context, txTotal)} | ${
+ context.getString(R.string.speed,
+ Formatter.formatFileSize(context, txRate))
+ }"
+ rxText.text = "▼ ${Formatter.formatFileSize(context, rxTotal)} | ${
+ context.getString(R.string.speed,
+ Formatter.formatFileSize(context, rxRate))
+ }"
+ }
+
+ fun testConnection() {
+
+ }
+
+}
diff --git a/app/src/main/java/io/nekohasekai/sagernet/widget/WindowInsetsListeners.kt b/app/src/main/java/io/nekohasekai/sagernet/widget/WindowInsetsListeners.kt
new file mode 100644
index 000000000..8f78831f9
--- /dev/null
+++ b/app/src/main/java/io/nekohasekai/sagernet/widget/WindowInsetsListeners.kt
@@ -0,0 +1,42 @@
+
+
+package io.nekohasekai.sagernet.widget
+
+import android.view.View
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.graphics.Insets
+import androidx.core.view.*
+import io.nekohasekai.sagernet.R
+
+object ListHolderListener : OnApplyWindowInsetsListener {
+ override fun onApplyWindowInsets(view: View, insets: WindowInsetsCompat): WindowInsetsCompat {
+ val statusBarInsets = insets.getInsets(WindowInsetsCompat.Type.statusBars())
+ view.setPadding(statusBarInsets.left,
+ statusBarInsets.top,
+ statusBarInsets.right,
+ statusBarInsets.bottom)
+ return WindowInsetsCompat.Builder(insets).apply {
+ setInsets(WindowInsetsCompat.Type.statusBars(), Insets.NONE)
+ setInsets(WindowInsetsCompat.Type.navigationBars(),
+ insets.getInsets(WindowInsetsCompat.Type.navigationBars()))
+ }.build()
+ }
+
+ fun setup(activity: AppCompatActivity) = activity.findViewById(android.R.id.content).let {
+ ViewCompat.setOnApplyWindowInsetsListener(it, ListHolderListener)
+ WindowCompat.setDecorFitsSystemWindows(activity.window, false)
+ }
+}
+
+object MainListListener : OnApplyWindowInsetsListener {
+ override fun onApplyWindowInsets(view: View, insets: WindowInsetsCompat) = insets.apply {
+ view.updatePadding(bottom = view.resources.getDimensionPixelOffset(R.dimen.main_list_padding_bottom) +
+ insets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom)
+ }
+}
+
+object ListListener : OnApplyWindowInsetsListener {
+ override fun onApplyWindowInsets(view: View, insets: WindowInsetsCompat) = insets.apply {
+ view.updatePadding(bottom = insets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom)
+ }
+}
diff --git a/app/src/main/jni/Android.mk b/app/src/main/jni/Android.mk
new file mode 100755
index 000000000..26a548c98
--- /dev/null
+++ b/app/src/main/jni/Android.mk
@@ -0,0 +1,128 @@
+# Copyright (C) 2009 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+#
+LOCAL_PATH := $(call my-dir)
+ROOT_PATH := $(LOCAL_PATH)
+
+BUILD_SHARED_EXECUTABLE := $(LOCAL_PATH)/build-shared-executable.mk
+
+########################################################
+## libancillary
+########################################################
+
+include $(CLEAR_VARS)
+
+ANCILLARY_SOURCE := fd_recv.c fd_send.c
+
+LOCAL_MODULE := libancillary
+LOCAL_CFLAGS += -I$(LOCAL_PATH)/libancillary
+
+LOCAL_SRC_FILES := $(addprefix libancillary/, $(ANCILLARY_SOURCE))
+
+include $(BUILD_STATIC_LIBRARY)
+
+########################################################
+## tun2socks
+########################################################
+
+include $(CLEAR_VARS)
+
+LOCAL_CFLAGS := -std=gnu99
+LOCAL_CFLAGS += -DBADVPN_THREADWORK_USE_PTHREAD -DBADVPN_LINUX -DBADVPN_BREACTOR_BADVPN -D_GNU_SOURCE
+LOCAL_CFLAGS += -DBADVPN_USE_SIGNALFD -DBADVPN_USE_EPOLL
+LOCAL_CFLAGS += -DBADVPN_LITTLE_ENDIAN -DBADVPN_THREAD_SAFE
+LOCAL_CFLAGS += -DNDEBUG -DANDROID
+# LOCAL_CFLAGS += -DTUN2SOCKS_JNI
+
+LOCAL_STATIC_LIBRARIES := libancillary
+
+LOCAL_C_INCLUDES:= \
+ $(LOCAL_PATH)/libancillary \
+ $(LOCAL_PATH)/badvpn/lwip/src/include/ipv4 \
+ $(LOCAL_PATH)/badvpn/lwip/src/include/ipv6 \
+ $(LOCAL_PATH)/badvpn/lwip/src/include \
+ $(LOCAL_PATH)/badvpn/lwip/custom \
+ $(LOCAL_PATH)/badvpn/
+
+TUN2SOCKS_SOURCES := \
+ base/BLog_syslog.c \
+ system/BReactor_badvpn.c \
+ system/BSignal.c \
+ system/BConnection_common.c \
+ system/BConnection_unix.c \
+ system/BTime.c \
+ system/BUnixSignal.c \
+ system/BNetwork.c \
+ flow/StreamRecvInterface.c \
+ flow/PacketRecvInterface.c \
+ flow/PacketPassInterface.c \
+ flow/StreamPassInterface.c \
+ flow/SinglePacketBuffer.c \
+ flow/BufferWriter.c \
+ flow/PacketBuffer.c \
+ flow/PacketStreamSender.c \
+ flow/PacketPassConnector.c \
+ flow/PacketProtoFlow.c \
+ flow/PacketPassFairQueue.c \
+ flow/PacketProtoEncoder.c \
+ flow/PacketProtoDecoder.c \
+ socksclient/BSocksClient.c \
+ tuntap/BTap.c \
+ lwip/src/core/udp.c \
+ lwip/src/core/memp.c \
+ lwip/src/core/init.c \
+ lwip/src/core/pbuf.c \
+ lwip/src/core/tcp.c \
+ lwip/src/core/tcp_out.c \
+ lwip/src/core/netif.c \
+ lwip/src/core/def.c \
+ lwip/src/core/ip.c \
+ lwip/src/core/mem.c \
+ lwip/src/core/tcp_in.c \
+ lwip/src/core/stats.c \
+ lwip/src/core/inet_chksum.c \
+ lwip/src/core/timeouts.c \
+ lwip/src/core/ipv4/icmp.c \
+ lwip/src/core/ipv4/igmp.c \
+ lwip/src/core/ipv4/ip4_addr.c \
+ lwip/src/core/ipv4/ip4_frag.c \
+ lwip/src/core/ipv4/ip4.c \
+ lwip/src/core/ipv4/autoip.c \
+ lwip/src/core/ipv6/ethip6.c \
+ lwip/src/core/ipv6/inet6.c \
+ lwip/src/core/ipv6/ip6_addr.c \
+ lwip/src/core/ipv6/mld6.c \
+ lwip/src/core/ipv6/dhcp6.c \
+ lwip/src/core/ipv6/icmp6.c \
+ lwip/src/core/ipv6/ip6.c \
+ lwip/src/core/ipv6/ip6_frag.c \
+ lwip/src/core/ipv6/nd6.c \
+ lwip/custom/sys.c \
+ tun2socks/tun2socks.c \
+ base/DebugObject.c \
+ base/BLog.c \
+ base/BPending.c \
+ system/BDatagram_unix.c \
+ flowextra/PacketPassInactivityMonitor.c \
+ tun2socks/SocksUdpGwClient.c \
+ udpgw_client/UdpGwClient.c
+
+LOCAL_MODULE := tun2socks
+
+LOCAL_LDLIBS := -ldl -llog
+
+LOCAL_SRC_FILES := $(addprefix badvpn/, $(TUN2SOCKS_SOURCES))
+
+include $(BUILD_SHARED_EXECUTABLE)
diff --git a/app/src/main/jni/Application.mk b/app/src/main/jni/Application.mk
new file mode 100644
index 000000000..ce095350e
--- /dev/null
+++ b/app/src/main/jni/Application.mk
@@ -0,0 +1 @@
+APP_STL := c++_static
diff --git a/app/src/main/jni/badvpn b/app/src/main/jni/badvpn
new file mode 160000
index 000000000..fb01eb983
--- /dev/null
+++ b/app/src/main/jni/badvpn
@@ -0,0 +1 @@
+Subproject commit fb01eb983915c9a09a690ad44c6028dd87500ec7
diff --git a/app/src/main/jni/build-shared-executable.mk b/app/src/main/jni/build-shared-executable.mk
new file mode 100644
index 000000000..05239df18
--- /dev/null
+++ b/app/src/main/jni/build-shared-executable.mk
@@ -0,0 +1,31 @@
+# Copyright (C) 2009 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# this file is included from Android.mk files to build a target-specific
+# executable program
+#
+# Modified by @Mygod, based on:
+# https://android.googlesource.com/platform/ndk/+/a355a4e/build/core/build-shared-library.mk
+# https://android.googlesource.com/platform/ndk/+/a355a4e/build/core/build-executable.mk
+LOCAL_BUILD_SCRIPT := BUILD_EXECUTABLE
+LOCAL_MAKEFILE := $(local-makefile)
+$(call check-defined-LOCAL_MODULE,$(LOCAL_BUILD_SCRIPT))
+$(call check-LOCAL_MODULE,$(LOCAL_MAKEFILE))
+$(call check-LOCAL_MODULE_FILENAME)
+# we are building target objects
+my := TARGET_
+$(call handle-module-filename,lib,$(TARGET_SONAME_EXTENSION))
+$(call handle-module-built)
+LOCAL_MODULE_CLASS := EXECUTABLE
+include $(BUILD_SYSTEM)/build-module.mk
diff --git a/app/src/main/jni/libancillary b/app/src/main/jni/libancillary
new file mode 160000
index 000000000..311e5d14f
--- /dev/null
+++ b/app/src/main/jni/libancillary
@@ -0,0 +1 @@
+Subproject commit 311e5d14f593f16c785bc6605220517eb1f21f6b
diff --git a/app/src/main/res/drawable-v21/ic_menu_camera.xml b/app/src/main/res/drawable-v21/ic_menu_camera.xml
new file mode 100644
index 000000000..634fe9221
--- /dev/null
+++ b/app/src/main/res/drawable-v21/ic_menu_camera.xml
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/app/src/main/res/drawable-v21/ic_menu_gallery.xml b/app/src/main/res/drawable-v21/ic_menu_gallery.xml
new file mode 100644
index 000000000..03c77099f
--- /dev/null
+++ b/app/src/main/res/drawable-v21/ic_menu_gallery.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable-v21/ic_menu_slideshow.xml b/app/src/main/res/drawable-v21/ic_menu_slideshow.xml
new file mode 100644
index 000000000..5e9e163a5
--- /dev/null
+++ b/app/src/main/res/drawable-v21/ic_menu_slideshow.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/background_profile.xml b/app/src/main/res/drawable/background_profile.xml
new file mode 100644
index 000000000..2b4d68ab8
--- /dev/null
+++ b/app/src/main/res/drawable/background_profile.xml
@@ -0,0 +1,25 @@
+
+
+ -
+
+
+
+
+ -
+
+
-
+
+
+
+
+
+
+
+ -
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/background_selectable.xml b/app/src/main/res/drawable/background_selectable.xml
new file mode 100644
index 000000000..b888994bd
--- /dev/null
+++ b/app/src/main/res/drawable/background_selectable.xml
@@ -0,0 +1,12 @@
+
+
+ -
+
+
-
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_action_assignment.xml b/app/src/main/res/drawable/ic_action_assignment.xml
new file mode 100644
index 000000000..9706a6ca1
--- /dev/null
+++ b/app/src/main/res/drawable/ic_action_assignment.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_action_copyright.xml b/app/src/main/res/drawable/ic_action_copyright.xml
new file mode 100644
index 000000000..fad6892cf
--- /dev/null
+++ b/app/src/main/res/drawable/ic_action_copyright.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_action_delete.xml b/app/src/main/res/drawable/ic_action_delete.xml
new file mode 100644
index 000000000..0e9d1eb3d
--- /dev/null
+++ b/app/src/main/res/drawable/ic_action_delete.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_action_description.xml b/app/src/main/res/drawable/ic_action_description.xml
new file mode 100644
index 000000000..98bda1ab6
--- /dev/null
+++ b/app/src/main/res/drawable/ic_action_description.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_action_dns.xml b/app/src/main/res/drawable/ic_action_dns.xml
new file mode 100644
index 000000000..dd725bed8
--- /dev/null
+++ b/app/src/main/res/drawable/ic_action_dns.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_action_done.xml b/app/src/main/res/drawable/ic_action_done.xml
new file mode 100644
index 000000000..8bf04e3f3
--- /dev/null
+++ b/app/src/main/res/drawable/ic_action_done.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_action_help_outline.xml b/app/src/main/res/drawable/ic_action_help_outline.xml
new file mode 100644
index 000000000..e7cf8ea21
--- /dev/null
+++ b/app/src/main/res/drawable/ic_action_help_outline.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_action_lock.xml b/app/src/main/res/drawable/ic_action_lock.xml
new file mode 100644
index 000000000..811a5b6b9
--- /dev/null
+++ b/app/src/main/res/drawable/ic_action_lock.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_action_lock_open.xml b/app/src/main/res/drawable/ic_action_lock_open.xml
new file mode 100644
index 000000000..169e93a75
--- /dev/null
+++ b/app/src/main/res/drawable/ic_action_lock_open.xml
@@ -0,0 +1,6 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_action_note_add.xml b/app/src/main/res/drawable/ic_action_note_add.xml
new file mode 100644
index 000000000..c8ffa8884
--- /dev/null
+++ b/app/src/main/res/drawable/ic_action_note_add.xml
@@ -0,0 +1,11 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_action_settings.xml b/app/src/main/res/drawable/ic_action_settings.xml
new file mode 100644
index 000000000..1f0802aa3
--- /dev/null
+++ b/app/src/main/res/drawable/ic_action_settings.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_app_shortcut_background.xml b/app/src/main/res/drawable/ic_app_shortcut_background.xml
new file mode 100644
index 000000000..d949e9f7a
--- /dev/null
+++ b/app/src/main/res/drawable/ic_app_shortcut_background.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_av_playlist_add.xml b/app/src/main/res/drawable/ic_av_playlist_add.xml
new file mode 100644
index 000000000..729667359
--- /dev/null
+++ b/app/src/main/res/drawable/ic_av_playlist_add.xml
@@ -0,0 +1,11 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_baseline_info_24.xml b/app/src/main/res/drawable/ic_baseline_info_24.xml
new file mode 100644
index 000000000..17255b7ae
--- /dev/null
+++ b/app/src/main/res/drawable/ic_baseline_info_24.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_baseline_insert_drive_file_24.xml b/app/src/main/res/drawable/ic_baseline_insert_drive_file_24.xml
new file mode 100644
index 000000000..18b306228
--- /dev/null
+++ b/app/src/main/res/drawable/ic_baseline_insert_drive_file_24.xml
@@ -0,0 +1,11 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_communication_phonelink_ring.xml b/app/src/main/res/drawable/ic_communication_phonelink_ring.xml
new file mode 100644
index 000000000..beaf1380f
--- /dev/null
+++ b/app/src/main/res/drawable/ic_communication_phonelink_ring.xml
@@ -0,0 +1,6 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_device_data_usage.xml b/app/src/main/res/drawable/ic_device_data_usage.xml
new file mode 100644
index 000000000..6431414dd
--- /dev/null
+++ b/app/src/main/res/drawable/ic_device_data_usage.xml
@@ -0,0 +1,6 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_device_developer_mode.xml b/app/src/main/res/drawable/ic_device_developer_mode.xml
new file mode 100644
index 000000000..1a41f6597
--- /dev/null
+++ b/app/src/main/res/drawable/ic_device_developer_mode.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_file_cloud_queue.xml b/app/src/main/res/drawable/ic_file_cloud_queue.xml
new file mode 100644
index 000000000..28d1f89c2
--- /dev/null
+++ b/app/src/main/res/drawable/ic_file_cloud_queue.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_file_file_upload.xml b/app/src/main/res/drawable/ic_file_file_upload.xml
new file mode 100644
index 000000000..11e906a05
--- /dev/null
+++ b/app/src/main/res/drawable/ic_file_file_upload.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_hardware_router.xml b/app/src/main/res/drawable/ic_hardware_router.xml
new file mode 100644
index 000000000..d6afc526d
--- /dev/null
+++ b/app/src/main/res/drawable/ic_hardware_router.xml
@@ -0,0 +1,6 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_image_camera_alt.xml b/app/src/main/res/drawable/ic_image_camera_alt.xml
new file mode 100644
index 000000000..fb564d3e0
--- /dev/null
+++ b/app/src/main/res/drawable/ic_image_camera_alt.xml
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_image_edit.xml b/app/src/main/res/drawable/ic_image_edit.xml
new file mode 100644
index 000000000..7e3c1b64a
--- /dev/null
+++ b/app/src/main/res/drawable/ic_image_edit.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_image_looks_6.xml b/app/src/main/res/drawable/ic_image_looks_6.xml
new file mode 100644
index 000000000..441f83efb
--- /dev/null
+++ b/app/src/main/res/drawable/ic_image_looks_6.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_image_photo.xml b/app/src/main/res/drawable/ic_image_photo.xml
new file mode 100644
index 000000000..722eadb9e
--- /dev/null
+++ b/app/src/main/res/drawable/ic_image_photo.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 000000000..d1ef753df
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_maps_360.xml b/app/src/main/res/drawable/ic_maps_360.xml
new file mode 100644
index 000000000..c507034c7
--- /dev/null
+++ b/app/src/main/res/drawable/ic_maps_360.xml
@@ -0,0 +1,11 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_maps_directions.xml b/app/src/main/res/drawable/ic_maps_directions.xml
new file mode 100644
index 000000000..5b549dfba
--- /dev/null
+++ b/app/src/main/res/drawable/ic_maps_directions.xml
@@ -0,0 +1,6 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_maps_directions_boat.xml b/app/src/main/res/drawable/ic_maps_directions_boat.xml
new file mode 100644
index 000000000..4b67183bc
--- /dev/null
+++ b/app/src/main/res/drawable/ic_maps_directions_boat.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_navigation_apps.xml b/app/src/main/res/drawable/ic_navigation_apps.xml
new file mode 100644
index 000000000..941ab03b6
--- /dev/null
+++ b/app/src/main/res/drawable/ic_navigation_apps.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_navigation_close.xml b/app/src/main/res/drawable/ic_navigation_close.xml
new file mode 100644
index 000000000..7caff12f8
--- /dev/null
+++ b/app/src/main/res/drawable/ic_navigation_close.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_navigation_menu.xml b/app/src/main/res/drawable/ic_navigation_menu.xml
new file mode 100644
index 000000000..f170e1fae
--- /dev/null
+++ b/app/src/main/res/drawable/ic_navigation_menu.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_notification_enhanced_encryption.xml b/app/src/main/res/drawable/ic_notification_enhanced_encryption.xml
new file mode 100644
index 000000000..b186b2254
--- /dev/null
+++ b/app/src/main/res/drawable/ic_notification_enhanced_encryption.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_qu_camera_launcher.xml b/app/src/main/res/drawable/ic_qu_camera_launcher.xml
new file mode 100644
index 000000000..d0a978198
--- /dev/null
+++ b/app/src/main/res/drawable/ic_qu_camera_launcher.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_qu_shadowsocks_foreground.xml b/app/src/main/res/drawable/ic_qu_shadowsocks_foreground.xml
new file mode 100644
index 000000000..8262da94b
--- /dev/null
+++ b/app/src/main/res/drawable/ic_qu_shadowsocks_foreground.xml
@@ -0,0 +1,11 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_qu_shadowsocks_launcher.xml b/app/src/main/res/drawable/ic_qu_shadowsocks_launcher.xml
new file mode 100755
index 000000000..f8bdfcfe3
--- /dev/null
+++ b/app/src/main/res/drawable/ic_qu_shadowsocks_launcher.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_service_active.xml b/app/src/main/res/drawable/ic_service_active.xml
new file mode 100755
index 000000000..33062676e
--- /dev/null
+++ b/app/src/main/res/drawable/ic_service_active.xml
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_service_busy.xml b/app/src/main/res/drawable/ic_service_busy.xml
new file mode 100755
index 000000000..3adac9f34
--- /dev/null
+++ b/app/src/main/res/drawable/ic_service_busy.xml
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_service_connected.xml b/app/src/main/res/drawable/ic_service_connected.xml
new file mode 100644
index 000000000..18c896070
--- /dev/null
+++ b/app/src/main/res/drawable/ic_service_connected.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_service_connecting.xml b/app/src/main/res/drawable/ic_service_connecting.xml
new file mode 100644
index 000000000..45f8f1559
--- /dev/null
+++ b/app/src/main/res/drawable/ic_service_connecting.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_service_idle.xml b/app/src/main/res/drawable/ic_service_idle.xml
new file mode 100755
index 000000000..205b81d48
--- /dev/null
+++ b/app/src/main/res/drawable/ic_service_idle.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_service_stopped.xml b/app/src/main/res/drawable/ic_service_stopped.xml
new file mode 100644
index 000000000..3d2e28bc9
--- /dev/null
+++ b/app/src/main/res/drawable/ic_service_stopped.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_service_stopping.xml b/app/src/main/res/drawable/ic_service_stopping.xml
new file mode 100644
index 000000000..87f9233b6
--- /dev/null
+++ b/app/src/main/res/drawable/ic_service_stopping.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_settings_password.xml b/app/src/main/res/drawable/ic_settings_password.xml
new file mode 100644
index 000000000..50b08a264
--- /dev/null
+++ b/app/src/main/res/drawable/ic_settings_password.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_social_emoji_symbols.xml b/app/src/main/res/drawable/ic_social_emoji_symbols.xml
new file mode 100644
index 000000000..77549efdc
--- /dev/null
+++ b/app/src/main/res/drawable/ic_social_emoji_symbols.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_social_share.xml b/app/src/main/res/drawable/ic_social_share.xml
new file mode 100644
index 000000000..da0c4fb6c
--- /dev/null
+++ b/app/src/main/res/drawable/ic_social_share.xml
@@ -0,0 +1,11 @@
+
+
+
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
new file mode 100644
index 000000000..a32a1d276
--- /dev/null
+++ b/app/src/main/res/layout/activity_main.xml
@@ -0,0 +1,110 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/app_bar_main.xml b/app/src/main/res/layout/app_bar_main.xml
new file mode 100644
index 000000000..2a1565abf
--- /dev/null
+++ b/app/src/main/res/layout/app_bar_main.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/configurtion_list_main.xml b/app/src/main/res/layout/configurtion_list_main.xml
new file mode 100644
index 000000000..e82b5ee74
--- /dev/null
+++ b/app/src/main/res/layout/configurtion_list_main.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/content_main.xml b/app/src/main/res/layout/content_main.xml
new file mode 100644
index 000000000..23466cc4c
--- /dev/null
+++ b/app/src/main/res/layout/content_main.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/group_list_main.xml b/app/src/main/res/layout/group_list_main.xml
new file mode 100644
index 000000000..ccaeed1ba
--- /dev/null
+++ b/app/src/main/res/layout/group_list_main.xml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/layout_profile.xml b/app/src/main/res/layout/layout_profile.xml
new file mode 100644
index 000000000..faea1f399
--- /dev/null
+++ b/app/src/main/res/layout/layout_profile.xml
@@ -0,0 +1,130 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/settings_activity.xml b/app/src/main/res/layout/settings_activity.xml
new file mode 100644
index 000000000..9ccbda590
--- /dev/null
+++ b/app/src/main/res/layout/settings_activity.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/menu/activity_main_drawer.xml b/app/src/main/res/menu/activity_main_drawer.xml
new file mode 100644
index 000000000..eb16e4313
--- /dev/null
+++ b/app/src/main/res/menu/activity_main_drawer.xml
@@ -0,0 +1,18 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/menu/main.xml b/app/src/main/res/menu/main.xml
new file mode 100644
index 000000000..412d5f844
--- /dev/null
+++ b/app/src/main/res/menu/main.xml
@@ -0,0 +1,9 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 000000000..7353dbd1f
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 000000000..7353dbd1f
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..382595236
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000..94e67db82
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 000000000..3c858780b
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..7170cbfe2
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000..b05cd8ab8
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 000000000..06efdec58
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..b84ff3867
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000..14553839f
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 000000000..f87ab4e80
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..c2359fcb8
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000..a25c92f9a
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 000000000..283b71201
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..cf5c82020
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
new file mode 100644
index 000000000..15ab3d100
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 000000000..b50793409
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/navigation/mobile_navigation.xml b/app/src/main/res/navigation/mobile_navigation.xml
new file mode 100644
index 000000000..68f3b5602
--- /dev/null
+++ b/app/src/main/res/navigation/mobile_navigation.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml
new file mode 100644
index 000000000..6cf9ed481
--- /dev/null
+++ b/app/src/main/res/values/arrays.xml
@@ -0,0 +1,12 @@
+
+
+
+ - Reply
+ - Reply to all
+
+
+
+ - reply
+ - reply_all
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
new file mode 100644
index 000000000..257adf9f3
--- /dev/null
+++ b/app/src/main/res/values/colors.xml
@@ -0,0 +1,39 @@
+
+
+
+ #F8BBD0
+ #F06292
+ #E91E63
+ #D81B60
+ #C2185B
+ #AD1457
+ #880E4F
+ #C51162
+
+ @color/material_pink_100
+ @color/material_pink_300
+ @color/material_pink_500
+ @color/material_pink_600
+ @color/material_pink_700
+ @color/material_pink_800
+ @color/material_pink_900
+ @color/material_pink_a700
+
+ @color/material_primary_500
+ @color/material_primary_700
+ @color/material_primary_500
+ @color/material_primary_800
+ @color/material_primary_900
+ @color/material_primary_300
+
+ @color/light_color_primary
+ @color/light_color_primary_dark
+ @color/light_color_primary_text
+
+ @color/material_primary_100
+ @color/material_primary_300
+ #ffab00
+
+ #FF000000
+ #FFFFFFFF
+
\ No newline at end of file
diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml
new file mode 100644
index 000000000..61f73fd8e
--- /dev/null
+++ b/app/src/main/res/values/dimens.xml
@@ -0,0 +1,14 @@
+
+ 16dp
+ 16dp
+ 8dp
+ 176dp
+ 16dp
+ 264dp
+ 8dp
+ 88dp
+ 8dp
+ 0dp
+ 0dp
+ 48dp
+
\ No newline at end of file
diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml
new file mode 100644
index 000000000..fbd18fe02
--- /dev/null
+++ b/app/src/main/res/values/ic_launcher_background.xml
@@ -0,0 +1,4 @@
+
+
+ #FFFF00
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 000000000..d3dcf1ceb
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,188 @@
+
+ SagerNet
+ Open navigation drawer
+ Close navigation drawer
+ Navigation header
+ Settings
+
+ Configuration
+ About
+
+ Default
+ 127.0.0.1:1080
+ socks5
+
+ Toggle
+ Send email
+
+
+ Service mode
+ Proxy only
+ VPN
+ Transproxy
+ Share over LAN
+ SOCKS5 proxy port
+ Local DNS port
+ Transproxy port
+
+ Remote DNS
+ %1$s↑\t%2$s↓
+ Sent: \t\t\t\t\t%3$s\t↑\t%1$s\nReceived: \t%4$s\t↓\t%2$s
+ %s/s
+ Check Connectivity
+ Testing…
+ Success: HTTPS handshake took %dms
+ Fail to detect internet connection: %s
+ Internet Unavailable
+ Error code: #%d
+
+
+ Profile Name
+ Server
+ Remote Port
+ Password
+ Encrypt Method
+
+
+ IPv6 Route
+ Redirect IPv6 traffic to remote
+ Metered Hint
+ Hint system to treat VPN as metered
+ Route
+ All
+ Bypass LAN
+ Bypass mainland China
+ Bypass LAN & mainland China
+ GFW List
+ China List
+ Apps VPN mode
+ Configure VPN mode for selected apps
+ On
+ Off
+ Mode
+ Bypass
+ Enable this option to bypass selected apps
+ Auto Connect
+ Enable Shadowsocks on startup/app update if it was running before
+ Allow Toggling in Lock Screen
+ Your selected profile information will be less protected
+
+ - 1 hostname configured
+ - %d hostnames configured
+
+ Send DNS over UDP
+ Requires UDP forwarding on server side
+ UDP Fallback
+
+
+ VPN Service
+ Proxy Service
+ Transproxy Service
+ SagerNet started.
+ Invalid server name
+ Failed to connect the remote server
+ Stop
+ Shutting down…
+ %s
+ Permission denied to create a VPN service
+ Failed to start VPN service. You might need to reboot your device.
+ No valid profile data found.
+
+
+ Please select a profile
+ Proxy/Password should not be empty
+ Connect
+
+
+ Profiles
+ Settings
+ FAQ
+ https://github.com/shadowsocks/shadowsocks-android/blob/master/.github/faq.md
+ About
+ Shadowsocks %s
+ Edit
+ Share
+ Add Profile
+ Apply Settings to All Profiles
+ Export…
+ Export to file…
+ Export to Clipboard
+ Import from Clipboard
+ Import from file…
+ Replace from file…
+ Successfully export!
+ Failed to export.
+ Successfully import!
+ Failed to import.
+
+
+ Profile config
+ Remove
+ Are you sure you want to remove this profile?
+ QR code
+ Add this Shadowsocks Profile?
+ Scan QR code
+ Manual Settings
+ Camera permission is required for scanning QR code.
+
+ - Removed
+ - %d items removed
+
+ Undo
+
+
+ Start the service
+ Connect to the current server
+ Connect to %s
+ Switch to %s
+ Use the current profile
+
+
+ Connecting…
+ Connected, tap to check connection
+ Not connected
+
+
+ Subscriptions
+ Add a subscription
+ Edit subscription
+ Refresh servers from subscription
+ Subscription Service
+ Syncing subscriptions… (%d of %d)
+ Finishing up…
+
+
+ Custom rules
+ Add rule(s)…
+ Subnet or Hostname PCRE pattern
+ Domain name and all its subdomain names
+ URL to online config
+ Edit rule
+ Cleartext HTTP traffic is insecure
+
+
+ Plugin
+ Configure…
+ Disabled
+ Unknown plugin %s
+ Warning: This plugin does not seem to come from a known trusted source.
+ This plugin might not work with Auto Connect
+ SocksSettingsActivity
+
+
+ Messages
+ Sync
+
+
+ Your signature
+ Default reply action
+
+
+ Sync email periodically
+ Download incoming attachments
+ Automatically download attachments for incoming emails
+
+ Only download attachments when manually requested
+ SettingsActivity
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
new file mode 100644
index 000000000..45e4e0998
--- /dev/null
+++ b/app/src/main/res/values/themes.xml
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/xml/root_preferences.xml b/app/src/main/res/xml/root_preferences.xml
new file mode 100644
index 000000000..8a3865721
--- /dev/null
+++ b/app/src/main/res/xml/root_preferences.xml
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/test/java/io/nekohasekai/sagernet/ExampleUnitTest.kt b/app/src/test/java/io/nekohasekai/sagernet/ExampleUnitTest.kt
new file mode 100644
index 000000000..40d34075f
--- /dev/null
+++ b/app/src/test/java/io/nekohasekai/sagernet/ExampleUnitTest.kt
@@ -0,0 +1,17 @@
+package io.nekohasekai.sagernet
+
+import org.junit.Test
+
+import org.junit.Assert.*
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * See [testing documentation](http://d.android.com/tools/testing).
+ */
+class ExampleUnitTest {
+ @Test
+ fun addition_isCorrect() {
+ assertEquals(4, 2 + 2)
+ }
+}
\ No newline at end of file
diff --git a/bin/init/env.sh b/bin/init/env.sh
new file mode 100755
index 000000000..f64975d4a
--- /dev/null
+++ b/bin/init/env.sh
@@ -0,0 +1,35 @@
+#!/bin/bash
+
+if [ -z "$ANDROID_HOME" ]; then
+ if [ -d "$HOME/Android/Sdk" ]; then
+ export ANDROID_HOME="$HOME/Android/Sdk"
+ elif [ -d "$HOME/.local/lib/android/sdk" ]; then
+ export ANDROID_HOME="$HOME/.local/lib/android/sdk"
+ fi
+fi
+
+_NDK="$ANDROID_HOME/ndk/21.4.7075529"
+[ -f "$_NDK/source.properties" ] || _NDK="$ANDROID_NDK_HOME"
+[ -f "$_NDK/source.properties" ] || _NDK="$NDK"
+[ -f "$_NDK/source.properties" ] || _NDK="$ANDROID_HOME/ndk-bundle"
+
+if [ ! -f "$_NDK/source.properties" ]; then
+ echo "Error: NDK not found."
+ exit 1
+fi
+
+export ANDROID_NDK_HOME=$_NDK
+export NDK=$_NDK
+export PROJECT=$(realpath .)
+
+if [ ! $(command -v go) ]; then
+ if [ -d /usr/lib/go-1.16 ]; then
+ export PATH=$PATH:/usr/lib/go-1.16/bin
+ elif [ -d $HOME/.go ]; then
+ export PATH=$PATH:$HOME/.go/bin
+ fi
+fi
+
+if [ $(command -v go) ]; then
+ export PATH=$PATH:$(go env GOPATH)/bin
+fi
diff --git a/bin/libs/v2ray.sh b/bin/libs/v2ray.sh
new file mode 100755
index 000000000..36b80da53
--- /dev/null
+++ b/bin/libs/v2ray.sh
@@ -0,0 +1,4 @@
+#!/bin/bash
+
+bin/libs/v2ray/init.sh
+bin/libs/v2ray/build.sh
\ No newline at end of file
diff --git a/bin/libs/v2ray/build.sh b/bin/libs/v2ray/build.sh
new file mode 100755
index 000000000..141348136
--- /dev/null
+++ b/bin/libs/v2ray/build.sh
@@ -0,0 +1,12 @@
+#!/usr/bin/env bash
+
+source "bin/init/env.sh"
+export GO111MOUDLE=on
+export GO386=softfloat
+
+cd "$PROJECT/v2ray"
+gomobile init
+gomobile bind -v -ldflags='-s -w' . || exit 1
+
+mkdir -p "$PROJECT/app/libs"
+/bin/cp -f libv2ray.aar "$PROJECT/app/libs"
diff --git a/bin/libs/v2ray/init.sh b/bin/libs/v2ray/init.sh
new file mode 100755
index 000000000..2f37ac04a
--- /dev/null
+++ b/bin/libs/v2ray/init.sh
@@ -0,0 +1,11 @@
+#!/usr/bin/env bash
+
+source "bin/init/env.sh"
+export GO111MOUDLE=on
+export PATH="$PATH:$(go env GOPATH)/bin"
+
+cd $PROJECT
+[ -f v2ray/go.mod ] || git submodule update --init v2ray
+cd v2ray
+git reset --hard && git clean -fdx
+go mod download -x && go get -v golang.org/x/mobile/cmd/... || exit 1
diff --git a/build.gradle b/build.gradle
new file mode 100644
index 000000000..9f1156b22
--- /dev/null
+++ b/build.gradle
@@ -0,0 +1,29 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+buildscript {
+ ext {
+ kotlin_version = "1.4.32"
+ }
+ repositories {
+ google()
+ jcenter()
+ }
+ dependencies {
+ classpath "com.android.tools.build:gradle:4.1.3"
+ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
+
+ // NOTE: Do not place your application dependencies here; they belong
+ // in the individual module build.gradle files
+ }
+}
+
+allprojects {
+ repositories {
+ google()
+ jcenter()
+ maven { url 'https://jitpack.io' }
+ }
+}
+
+task clean(type: Delete) {
+ delete rootProject.buildDir
+}
\ No newline at end of file
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 000000000..98bed167d
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,21 @@
+# Project-wide Gradle settings.
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. More details, visit
+# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
+# org.gradle.parallel=true
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app"s APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true
+# Automatically convert third-party libraries to use AndroidX
+android.enableJetifier=true
+# Kotlin code style for this project: "official" or "obsolete":
+kotlin.code.style=official
\ No newline at end of file
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 000000000..f6b961fd5
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 000000000..014b41d2f
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Tue Mar 02 12:11:09 CST 2021
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-6.8.3-bin.zip
diff --git a/gradlew b/gradlew
new file mode 100755
index 000000000..cccdd3d51
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,172 @@
+#!/usr/bin/env sh
+
+##############################################################################
+##
+## Gradle start up script for UN*X
+##
+##############################################################################
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS=""
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn () {
+ echo "$*"
+}
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "`uname`" in
+ CYGWIN* )
+ cygwin=true
+ ;;
+ Darwin* )
+ darwin=true
+ ;;
+ MINGW* )
+ msys=true
+ ;;
+ NONSTOP* )
+ nonstop=true
+ ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD="java"
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin, switch paths to Windows format before running java
+if $cygwin ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+ JAVACMD=`cygpath --unix "$JAVACMD"`
+
+ # We build the pattern for arguments to be converted via cygpath
+ ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+ SEP=""
+ for dir in $ROOTDIRSRAW ; do
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
+ SEP="|"
+ done
+ OURCYGPATTERN="(^($ROOTDIRS))"
+ # Add a user-defined pattern to the cygpath arguments
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+ fi
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ i=0
+ for arg in "$@" ; do
+ CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+ CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
+
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
+ eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+ else
+ eval `echo args$i`="\"$arg\""
+ fi
+ i=$((i+1))
+ done
+ case $i in
+ (0) set -- ;;
+ (1) set -- "$args0" ;;
+ (2) set -- "$args0" "$args1" ;;
+ (3) set -- "$args0" "$args1" "$args2" ;;
+ (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ esac
+fi
+
+# Escape application args
+save () {
+ for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
+ echo " "
+}
+APP_ARGS=$(save "$@")
+
+# Collect all arguments for the java command, following the shell quoting and substitution rules
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+
+# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
+if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
+ cd "$(dirname "$0")"
+fi
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 000000000..f9553162f
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,84 @@
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS=
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto init
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto init
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:init
+@rem Get command-line arguments, handling Windows variants
+
+if not "%OS%" == "Windows_NT" goto win9xME_args
+
+:win9xME_args
+@rem Slurp the command line arguments.
+set CMD_LINE_ARGS=
+set _SKIP=2
+
+:win9xME_args_slurp
+if "x%~1" == "x" goto execute
+
+set CMD_LINE_ARGS=%*
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/run b/run
new file mode 100755
index 000000000..df596d7de
--- /dev/null
+++ b/run
@@ -0,0 +1,14 @@
+#!/bin/bash
+
+EXEC=""
+TARGET="bin"
+for e in $@; do
+ TARGET="$TARGET/$e"
+ shift
+ if [ -x "${TARGET}.sh" ]; then
+ EXEC="${TARGET}.sh"
+ fi
+done
+
+echo ">> $EXEC"
+exec "$EXEC" $@
\ No newline at end of file
diff --git a/settings.gradle b/settings.gradle
new file mode 100644
index 000000000..f2b9cecfb
--- /dev/null
+++ b/settings.gradle
@@ -0,0 +1,2 @@
+include ':app'
+rootProject.name = "SagerNet"
\ No newline at end of file
diff --git a/v2ray b/v2ray
new file mode 160000
index 000000000..a697372e9
--- /dev/null
+++ b/v2ray
@@ -0,0 +1 @@
+Subproject commit a697372e996000fa748f87b4c2bad4da038b5ddf