Bläddra i källkod

Create project

世界 4 år sedan
incheckning
8db67802dc
100 ändrade filer med 5698 tillägg och 0 borttagningar
  1. 16 0
      .gitignore
  2. 9 0
      .gitmodules
  3. 3 0
      .idea/.gitignore
  4. 140 0
      .idea/codeStyles/Project.xml
  5. 5 0
      .idea/codeStyles/codeStyleConfig.xml
  6. 6 0
      .idea/compiler.xml
  7. 11 0
      .idea/dictionaries/sekai.xml
  8. 24 0
      .idea/gradle.xml
  9. 7 0
      .idea/inspectionProfiles/Project_Default.xml
  10. 30 0
      .idea/jarRepositories.xml
  11. 9 0
      .idea/misc.xml
  12. 9 0
      .idea/vcs.xml
  13. 10 0
      README
  14. 1 0
      app/.gitignore
  15. 113 0
      app/build.gradle
  16. 23 0
      app/proguard-rules.pro
  17. 197 0
      app/schemas/io.nekohasekai.sagernet.database.SagerDatabase/1.json
  18. 46 0
      app/schemas/io.nekohasekai.sagernet.database.preference.PublicDatabase/1.json
  19. 24 0
      app/src/androidTest/java/io/nekohasekai/sagernet/ExampleInstrumentedTest.kt
  20. 66 0
      app/src/main/AndroidManifest.xml
  21. 13 0
      app/src/main/aidl/com/github/shadowsocks/aidl/IShadowsocksService.aidl
  22. 10 0
      app/src/main/aidl/com/github/shadowsocks/aidl/IShadowsocksServiceCallback.aidl
  23. 3 0
      app/src/main/aidl/com/github/shadowsocks/aidl/TrafficStats.aidl
  24. BIN
      app/src/main/ic_launcher-playstore.png
  25. 21 0
      app/src/main/java/com/github/shadowsocks/aidl/TrafficStats.kt
  26. 23 0
      app/src/main/java/io/nekohasekai/sagernet/Constants.kt
  27. 103 0
      app/src/main/java/io/nekohasekai/sagernet/SagerApp.kt
  28. 308 0
      app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt
  29. 35 0
      app/src/main/java/io/nekohasekai/sagernet/bg/Executable.kt
  30. 110 0
      app/src/main/java/io/nekohasekai/sagernet/bg/GuardedProcessPool.kt
  31. 83 0
      app/src/main/java/io/nekohasekai/sagernet/bg/ProxyInstance.kt
  32. 20 0
      app/src/main/java/io/nekohasekai/sagernet/bg/ProxyService.kt
  33. 149 0
      app/src/main/java/io/nekohasekai/sagernet/bg/SagerConnection.kt
  34. 111 0
      app/src/main/java/io/nekohasekai/sagernet/bg/ServiceNotification.kt
  35. 349 0
      app/src/main/java/io/nekohasekai/sagernet/bg/VpnService.kt
  36. 41 0
      app/src/main/java/io/nekohasekai/sagernet/database/DataStore.kt
  37. 65 0
      app/src/main/java/io/nekohasekai/sagernet/database/ProxyEntity.kt
  38. 36 0
      app/src/main/java/io/nekohasekai/sagernet/database/ProxyGroup.kt
  39. 42 0
      app/src/main/java/io/nekohasekai/sagernet/database/SagerDatabase.kt
  40. 28 0
      app/src/main/java/io/nekohasekai/sagernet/database/preference/EditTextPreferenceModifiers.kt
  41. 119 0
      app/src/main/java/io/nekohasekai/sagernet/database/preference/KeyValuePair.kt
  42. 7 0
      app/src/main/java/io/nekohasekai/sagernet/database/preference/OnPreferenceDataStoreChangeListener.kt
  43. 31 0
      app/src/main/java/io/nekohasekai/sagernet/database/preference/PublicDatabase.kt
  44. 80 0
      app/src/main/java/io/nekohasekai/sagernet/database/preference/RoomPreferenceDataStore.kt
  45. 25 0
      app/src/main/java/io/nekohasekai/sagernet/fmt/AbstractBean.java
  46. 55 0
      app/src/main/java/io/nekohasekai/sagernet/fmt/KryoConverters.java
  47. 19 0
      app/src/main/java/io/nekohasekai/sagernet/fmt/gson/GsonConverters.java
  48. 8 0
      app/src/main/java/io/nekohasekai/sagernet/fmt/gson/Gsons.kt
  49. 42 0
      app/src/main/java/io/nekohasekai/sagernet/fmt/gson/JsonLazyAdapter.java
  50. 17 0
      app/src/main/java/io/nekohasekai/sagernet/fmt/gson/JsonLazyFactory.java
  51. 52 0
      app/src/main/java/io/nekohasekai/sagernet/fmt/gson/JsonLazyInterface.java
  52. 30 0
      app/src/main/java/io/nekohasekai/sagernet/fmt/gson/JsonOr.java
  53. 46 0
      app/src/main/java/io/nekohasekai/sagernet/fmt/gson/JsonOrAdapter.java
  54. 32 0
      app/src/main/java/io/nekohasekai/sagernet/fmt/gson/JsonOrAdapterFactory.java
  55. 31 0
      app/src/main/java/io/nekohasekai/sagernet/fmt/socks/SOCKSBean.java
  56. 35 0
      app/src/main/java/io/nekohasekai/sagernet/fmt/socks/SOCKSFmt.kt
  57. 693 0
      app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/V2rayConfig.java
  58. 77 0
      app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/VMessBean.java
  59. 245 0
      app/src/main/java/io/nekohasekai/sagernet/fmt/v2ray/VMessFmt.kt
  60. 15 0
      app/src/main/java/io/nekohasekai/sagernet/ktx/Asyncs.kt
  61. 14 0
      app/src/main/java/io/nekohasekai/sagernet/ktx/Dimens.kt
  62. 10 0
      app/src/main/java/io/nekohasekai/sagernet/ktx/Kryos.kt
  63. 66 0
      app/src/main/java/io/nekohasekai/sagernet/ktx/Logs.kt
  64. 36 0
      app/src/main/java/io/nekohasekai/sagernet/ktx/Preferences.kt
  65. 117 0
      app/src/main/java/io/nekohasekai/sagernet/ktx/Utils.kt
  66. 24 0
      app/src/main/java/io/nekohasekai/sagernet/ui/AboutFragment.kt
  67. 85 0
      app/src/main/java/io/nekohasekai/sagernet/ui/ConfigurationFragment.kt
  68. 182 0
      app/src/main/java/io/nekohasekai/sagernet/ui/MainActivity.kt
  69. 71 0
      app/src/main/java/io/nekohasekai/sagernet/ui/VpnRequestActivity.kt
  70. 60 0
      app/src/main/java/io/nekohasekai/sagernet/ui/configuration/ConfigurationAdapter.kt
  71. 68 0
      app/src/main/java/io/nekohasekai/sagernet/ui/configuration/ConfigurationHolder.kt
  72. 39 0
      app/src/main/java/io/nekohasekai/sagernet/ui/configuration/GroupFragment.kt
  73. 45 0
      app/src/main/java/io/nekohasekai/sagernet/ui/configuration/GroupPagerAdapter.kt
  74. 44 0
      app/src/main/java/io/nekohasekai/sagernet/ui/settings/SettingsActivity.kt
  75. 140 0
      app/src/main/java/io/nekohasekai/sagernet/utils/Commandline.kt
  76. 20 0
      app/src/main/java/io/nekohasekai/sagernet/utils/DeviceStorageApp.kt
  77. 86 0
      app/src/main/java/io/nekohasekai/sagernet/utils/Subnet.kt
  78. 57 0
      app/src/main/java/io/nekohasekai/sagernet/widget/AutoCollapseTextView.kt
  79. 100 0
      app/src/main/java/io/nekohasekai/sagernet/widget/ServiceButton.kt
  80. 121 0
      app/src/main/java/io/nekohasekai/sagernet/widget/StatsBar.kt
  81. 42 0
      app/src/main/java/io/nekohasekai/sagernet/widget/WindowInsetsListeners.kt
  82. 128 0
      app/src/main/jni/Android.mk
  83. 1 0
      app/src/main/jni/Application.mk
  84. 1 0
      app/src/main/jni/badvpn
  85. 31 0
      app/src/main/jni/build-shared-executable.mk
  86. 1 0
      app/src/main/jni/libancillary
  87. 12 0
      app/src/main/res/drawable-v21/ic_menu_camera.xml
  88. 9 0
      app/src/main/res/drawable-v21/ic_menu_gallery.xml
  89. 9 0
      app/src/main/res/drawable-v21/ic_menu_slideshow.xml
  90. 25 0
      app/src/main/res/drawable/background_profile.xml
  91. 12 0
      app/src/main/res/drawable/background_selectable.xml
  92. 10 0
      app/src/main/res/drawable/ic_action_assignment.xml
  93. 9 0
      app/src/main/res/drawable/ic_action_copyright.xml
  94. 10 0
      app/src/main/res/drawable/ic_action_delete.xml
  95. 10 0
      app/src/main/res/drawable/ic_action_description.xml
  96. 10 0
      app/src/main/res/drawable/ic_action_dns.xml
  97. 10 0
      app/src/main/res/drawable/ic_action_done.xml
  98. 9 0
      app/src/main/res/drawable/ic_action_help_outline.xml
  99. 10 0
      app/src/main/res/drawable/ic_action_lock.xml
  100. 6 0
      app/src/main/res/drawable/ic_action_lock_open.xml

+ 16 - 0
.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/

+ 9 - 0
.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

+ 3 - 0
.idea/.gitignore

@@ -0,0 +1,3 @@
+# Default ignored files
+/shelf/
+/workspace.xml

+ 140 - 0
.idea/codeStyles/Project.xml

@@ -0,0 +1,140 @@
+<component name="ProjectCodeStyleConfiguration">
+  <code_scheme name="Project" version="173">
+    <JetCodeStyleSettings>
+      <option name="PACKAGES_TO_USE_STAR_IMPORTS">
+        <value>
+          <package name="java.util" alias="false" withSubpackages="false" />
+          <package name="kotlinx.android.synthetic" alias="false" withSubpackages="true" />
+          <package name="io.ktor" alias="false" withSubpackages="true" />
+        </value>
+      </option>
+      <option name="PACKAGES_IMPORT_LAYOUT">
+        <value>
+          <package name="" alias="false" withSubpackages="true" />
+          <package name="java" alias="false" withSubpackages="true" />
+          <package name="javax" alias="false" withSubpackages="true" />
+          <package name="kotlin" alias="false" withSubpackages="true" />
+          <package name="" alias="true" withSubpackages="true" />
+        </value>
+      </option>
+      <option name="ALLOW_TRAILING_COMMA" value="true" />
+      <option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
+    </JetCodeStyleSettings>
+    <codeStyleSettings language="XML">
+      <option name="FORCE_REARRANGE_MODE" value="1" />
+      <indentOptions>
+        <option name="CONTINUATION_INDENT_SIZE" value="4" />
+      </indentOptions>
+      <arrangement>
+        <rules>
+          <section>
+            <rule>
+              <match>
+                <AND>
+                  <NAME>xmlns:android</NAME>
+                  <XML_ATTRIBUTE />
+                  <XML_NAMESPACE>^$</XML_NAMESPACE>
+                </AND>
+              </match>
+            </rule>
+          </section>
+          <section>
+            <rule>
+              <match>
+                <AND>
+                  <NAME>xmlns:.*</NAME>
+                  <XML_ATTRIBUTE />
+                  <XML_NAMESPACE>^$</XML_NAMESPACE>
+                </AND>
+              </match>
+              <order>BY_NAME</order>
+            </rule>
+          </section>
+          <section>
+            <rule>
+              <match>
+                <AND>
+                  <NAME>.*:id</NAME>
+                  <XML_ATTRIBUTE />
+                  <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
+                </AND>
+              </match>
+            </rule>
+          </section>
+          <section>
+            <rule>
+              <match>
+                <AND>
+                  <NAME>.*:name</NAME>
+                  <XML_ATTRIBUTE />
+                  <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
+                </AND>
+              </match>
+            </rule>
+          </section>
+          <section>
+            <rule>
+              <match>
+                <AND>
+                  <NAME>name</NAME>
+                  <XML_ATTRIBUTE />
+                  <XML_NAMESPACE>^$</XML_NAMESPACE>
+                </AND>
+              </match>
+            </rule>
+          </section>
+          <section>
+            <rule>
+              <match>
+                <AND>
+                  <NAME>style</NAME>
+                  <XML_ATTRIBUTE />
+                  <XML_NAMESPACE>^$</XML_NAMESPACE>
+                </AND>
+              </match>
+            </rule>
+          </section>
+          <section>
+            <rule>
+              <match>
+                <AND>
+                  <NAME>.*</NAME>
+                  <XML_ATTRIBUTE />
+                  <XML_NAMESPACE>^$</XML_NAMESPACE>
+                </AND>
+              </match>
+              <order>BY_NAME</order>
+            </rule>
+          </section>
+          <section>
+            <rule>
+              <match>
+                <AND>
+                  <NAME>.*</NAME>
+                  <XML_ATTRIBUTE />
+                  <XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
+                </AND>
+              </match>
+              <order>ANDROID_ATTRIBUTE_ORDER</order>
+            </rule>
+          </section>
+          <section>
+            <rule>
+              <match>
+                <AND>
+                  <NAME>.*</NAME>
+                  <XML_ATTRIBUTE />
+                  <XML_NAMESPACE>.*</XML_NAMESPACE>
+                </AND>
+              </match>
+              <order>BY_NAME</order>
+            </rule>
+          </section>
+        </rules>
+      </arrangement>
+    </codeStyleSettings>
+    <codeStyleSettings language="kotlin">
+      <option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
+    </codeStyleSettings>
+  </code_scheme>
+</component>

+ 5 - 0
.idea/codeStyles/codeStyleConfig.xml

@@ -0,0 +1,5 @@
+<component name="ProjectCodeStyleConfiguration">
+  <state>
+    <option name="USE_PER_PROJECT_SETTINGS" value="true" />
+  </state>
+</component>

+ 6 - 0
.idea/compiler.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="CompilerConfiguration">
+    <bytecodeTargetLevel target="1.8" />
+  </component>
+</project>

+ 11 - 0
.idea/dictionaries/sekai.xml

@@ -0,0 +1,11 @@
+<component name="ProjectDictionaryState">
+  <dictionary name="sekai">
+    <words>
+      <w>downlink</w>
+      <w>gson</w>
+      <w>snackbar</w>
+      <w>uplink</w>
+      <w>vmess</w>
+    </words>
+  </dictionary>
+</component>

+ 24 - 0
.idea/gradle.xml

@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="GradleMigrationSettings" migrationVersion="1" />
+  <component name="GradleSettings">
+    <option name="linkedExternalProjectsSettings">
+      <GradleProjectSettings>
+        <option name="testRunner" value="PLATFORM" />
+        <option name="disableWrapperSourceDistributionNotification" value="true" />
+        <option name="distributionType" value="DEFAULT_WRAPPED" />
+        <option name="externalProjectPath" value="$PROJECT_DIR$" />
+        <option name="gradleHome" value="$USER_HOME$/.gradle/wrapper/dists/gradle-6.8.3-bin/7ykxq50lst7lb7wx1nijpicxn/gradle-6.8.3" />
+        <option name="gradleJvm" value="11" />
+        <option name="modules">
+          <set>
+            <option value="$PROJECT_DIR$" />
+            <option value="$PROJECT_DIR$/app" />
+          </set>
+        </option>
+        <option name="resolveModulePerSourceSet" value="false" />
+        <option name="useQualifiedModuleNames" value="true" />
+      </GradleProjectSettings>
+    </option>
+  </component>
+</project>

+ 7 - 0
.idea/inspectionProfiles/Project_Default.xml

@@ -0,0 +1,7 @@
+<component name="InspectionProjectProfileManager">
+  <profile version="1.0">
+    <option name="myName" value="Project Default" />
+    <inspection_tool class="HasPlatformType" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
+    <inspection_tool class="MemberVisibilityCanBePrivate" enabled="false" level="INFO" enabled_by_default="false" />
+  </profile>
+</component>

+ 30 - 0
.idea/jarRepositories.xml

@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="RemoteRepositoriesConfiguration">
+    <remote-repository>
+      <option name="id" value="central" />
+      <option name="name" value="Maven Central repository" />
+      <option name="url" value="https://repo1.maven.org/maven2" />
+    </remote-repository>
+    <remote-repository>
+      <option name="id" value="jboss.community" />
+      <option name="name" value="JBoss Community repository" />
+      <option name="url" value="https://repository.jboss.org/nexus/content/repositories/public/" />
+    </remote-repository>
+    <remote-repository>
+      <option name="id" value="BintrayJCenter" />
+      <option name="name" value="BintrayJCenter" />
+      <option name="url" value="https://jcenter.bintray.com/" />
+    </remote-repository>
+    <remote-repository>
+      <option name="id" value="Google" />
+      <option name="name" value="Google" />
+      <option name="url" value="https://dl.google.com/dl/android/maven2/" />
+    </remote-repository>
+    <remote-repository>
+      <option name="id" value="maven" />
+      <option name="name" value="maven" />
+      <option name="url" value="https://jitpack.io" />
+    </remote-repository>
+  </component>
+</project>

+ 9 - 0
.idea/misc.xml

@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="ProjectRootManager" version="2" languageLevel="JDK_1_8" default="false" project-jdk-name="11" project-jdk-type="JavaSDK">
+    <output url="file://$PROJECT_DIR$/build/classes" />
+  </component>
+  <component name="ProjectType">
+    <option name="id" value="Android" />
+  </component>
+</project>

+ 9 - 0
.idea/vcs.xml

@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="VcsDirectoryMappings">
+    <mapping directory="$PROJECT_DIR$" vcs="Git" />
+    <mapping directory="$PROJECT_DIR$/app/src/main/jni/badvpn" vcs="Git" />
+    <mapping directory="$PROJECT_DIR$/app/src/main/jni/libancillary" vcs="Git" />
+    <mapping directory="$PROJECT_DIR$/v2ray" vcs="Git" />
+  </component>
+</project>

+ 10 - 0
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/.

+ 1 - 0
app/.gitignore

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

+ 113 - 0
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"
+
+}

+ 23 - 0
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

+ 197 - 0
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')"
+    ]
+  }
+}

+ 46 - 0
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')"
+    ]
+  }
+}

+ 24 - 0
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)
+    }
+}

+ 66 - 0
app/src/main/AndroidManifest.xml

@@ -0,0 +1,66 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="io.nekohasekai.sagernet">
+
+    <permission
+        android:name="${applicationId}.SERVICE"
+        android:protectionLevel="signature" />
+
+    <uses-permission android:name="${applicationId}.SERVICE" />
+    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
+    <uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
+    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
+    <uses-permission android:name="android.permission.INTERNET" />
+    <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
+    <uses-permission android:name="android.permission.WAKE_LOCK" />
+    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
+
+    <uses-feature
+        android:name="android.software.leanback"
+        android:required="false" />
+
+    <application
+        android:name=".SagerApp"
+        android:allowBackup="false"
+        android:icon="@mipmap/ic_launcher"
+        android:label="@string/app_name"
+        android:roundIcon="@mipmap/ic_launcher_round"
+        android:supportsRtl="true"
+        android:theme="@style/Theme.SagerNet.Immersive">
+        <activity
+            android:name=".ui.settings.SettingsActivity"
+            android:label="@string/title_activity_settings"></activity>
+        <activity
+            android:name=".ui.entity.SocksSettingsActivity"
+            android:label="@string/title_activity_socks_settings" />
+        <activity
+            android:name=".ui.MainActivity"
+            android:label="@string/app_name"
+            android:theme="@style/Theme.SagerNet.Immersive.Navigation">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+        </activity>
+        <activity android:name=".ui.VpnRequestActivity" />
+
+        <service
+            android:name=".bg.ProxyService"
+            android:process=":bg" />
+        <service
+            android:name=".bg.VpnService"
+            android:exported="false"
+            android:label="@string/app_name"
+            android:permission="android.permission.BIND_VPN_SERVICE"
+            android:process=":bg">
+            <intent-filter>
+                <action android:name="android.net.VpnService" />
+            </intent-filter>
+        </service>
+        <service
+            android:name="androidx.room.MultiInstanceInvalidationService"
+            android:process=":bg" />
+    </application>
+
+</manifest>

+ 13 - 0
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);
+}

+ 10 - 0
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);
+}

+ 3 - 0
app/src/main/aidl/com/github/shadowsocks/aidl/TrafficStats.aidl

@@ -0,0 +1,3 @@
+package com.github.shadowsocks.aidl;
+
+parcelable TrafficStats;

BIN
app/src/main/ic_launcher-playstore.png


+ 21 - 0
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)
+}

+ 23 - 0
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"
+}

+ 103 - 0
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<ActivityManager>()!! }
+        val clipboard by lazy { application.getSystemService<ClipboardManager>()!! }
+        val connectivity by lazy { application.getSystemService<ConnectivityManager>()!! }
+        val notification by lazy { application.getSystemService<NotificationManager>()!! }
+        val user by lazy { application.getSystemService<UserManager>()!! }
+        val packageInfo: PackageInfo by lazy { application.getPackageInfo(application.packageName) }
+        val directBootSupported by lazy {
+            Build.VERSION.SDK_INT >= 24 && application.getSystemService<DevicePolicyManager>()?.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()
+    }
+
+}

+ 308 - 0
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<IShadowsocksServiceCallback>() {
+            override fun onCallbackDied(callback: IShadowsocksServiceCallback?, cookie: Any?) {
+                super.onCallbackDied(callback, cookie)
+                stopListeningForBandwidth(callback ?: return)
+            }
+        }
+        private val bandwidthListeners =
+            mutableMapOf<IBinder, Long>()  // 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<Long>) = 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
+        }
+    }
+
+}

+ 35 - 0
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)
+                }
+            }
+        }
+    }
+}

+ 110 - 0
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<String>) {
+        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<Int>()
+            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<String>, 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() } }
+    }
+}

+ 83 - 0
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
+        }
+    }
+
+
+}

+ 20 - 0
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<BaseService.Interface>.onStartCommand(intent, flags, startId)
+
+    override fun onDestroy() {
+        super.onDestroy()
+        data.binder.close()
+    }
+}

+ 149 - 0
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
+    }
+}

+ 111 - 0
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<PowerManager>()?.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)
+    }
+}

+ 349 - 0
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 <T> 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<BaseVpnService>.onBind(intent)
+        else -> super<BaseService.Interface>.onBind(intent)
+    }
+
+    override val data = BaseService.Data(this)
+    override val tag = "SagerNetVpnService"
+    override fun createNotification(profileName: String) =
+        ServiceNotification(this, profileName, "service-vpn")
+
+    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+        if (DataStore.serviceMode == Key.MODE_VPN) {
+            if (prepare(this) != null) {
+                startActivity(Intent(this,
+                    VpnRequestActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
+            } else return super<BaseService.Interface>.onStartCommand(intent, flags, startId)
+        }
+        stopRunner()
+        return Service.START_NOT_STICKY
+    }
+
+    inner class NullConnectionException : NullPointerException(), BaseService.ExpectedException {
+        override fun getLocalizedMessage() = getString(R.string.reboot_required)
+    }
+
+    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
+        }
+    }
+
+
+}

+ 41 - 0
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")
+
+}

+ 65 - 0
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<ProxyEntity>
+
+        @Query("SELECT * FROM proxy_entities WHERE id = :proxyId")
+        fun getById(proxyId: Long): ProxyEntity?
+
+        @Insert
+        fun addProxy(proxy: ProxyEntity)
+
+        @Update
+        fun updateProxy(proxy: ProxyEntity)
+
+    }
+}

+ 36 - 0
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<String> = 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<ProxyGroup>
+
+        @Query("SELECT * FROM proxy_groups WHERE id = :groupId")
+        fun getById(groupId: Long): ProxyGroup?
+
+        @Delete
+        fun delete(group: ProxyGroup)
+
+        @Insert
+        fun createGroup(group: ProxyGroup)
+
+    }
+
+}

+ 42 - 0
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
+
+}

+ 28 - 0
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)
+        }
+    }
+}

+ 119 - 0
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<String>?
+        get() = if (valueType == TYPE_STRING_SET) {
+            val buffer = ByteBuffer.wrap(value)
+            val result = HashSet<String>()
+            while (buffer.hasRemaining()) {
+                val chArr = ByteArray(buffer.int)
+                buffer.get(chArr)
+                result.add(String(chArr))
+            }
+            result
+        } else null
+
+    @Ignore
+    constructor(key: String) : this() {
+        this.key = key
+    }
+
+    // putting null requires using DataStore
+    fun put(value: Boolean): KeyValuePair {
+        valueType = TYPE_BOOLEAN
+        this.value = ByteBuffer.allocate(1).put((if (value) 1 else 0).toByte()).array()
+        return this
+    }
+
+    fun put(value: Float): KeyValuePair {
+        valueType = TYPE_FLOAT
+        this.value = ByteBuffer.allocate(4).putFloat(value).array()
+        return this
+    }
+
+    @Suppress("DEPRECATION")
+    @Deprecated("Use long.")
+    fun put(value: Int): KeyValuePair {
+        valueType = TYPE_INT
+        this.value = ByteBuffer.allocate(4).putInt(value).array()
+        return this
+    }
+
+    fun put(value: Long): KeyValuePair {
+        valueType = TYPE_LONG
+        this.value = ByteBuffer.allocate(8).putLong(value).array()
+        return this
+    }
+
+    fun put(value: String): KeyValuePair {
+        valueType = TYPE_STRING
+        this.value = value.toByteArray()
+        return this
+    }
+
+    fun put(value: Set<String>): KeyValuePair {
+        valueType = TYPE_STRING_SET
+        val stream = ByteArrayOutputStream()
+        val intBuffer = ByteBuffer.allocate(4)
+        for (v in value) {
+            intBuffer.rewind()
+            stream.write(intBuffer.putInt(v.length).array())
+            stream.write(v.toByteArray())
+        }
+        this.value = stream.toByteArray()
+        return this
+    }
+}

+ 7 - 0
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)
+}

+ 31 - 0
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
+
+}

+ 80 - 0
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<String>?) =
+        getStringSet(key) ?: defValue
+
+    fun putBoolean(key: String, value: Boolean?) =
+        if (value == null) remove(key) else putBoolean(key, value)
+
+    fun putFloat(key: String, value: Float?) =
+        if (value == null) remove(key) else putFloat(key, value)
+
+    fun putInt(key: String, value: Int?) =
+        if (value == null) remove(key) else putLong(key, value.toLong())
+
+    fun putLong(key: String, value: Long?) = if (value == null) remove(key) else putLong(key, value)
+    override fun putBoolean(key: String, value: Boolean) {
+        kvPairDao.put(KeyValuePair(key).put(value))
+        fireChangeListener(key)
+    }
+
+    override fun putFloat(key: String, value: Float) {
+        kvPairDao.put(KeyValuePair(key).put(value))
+        fireChangeListener(key)
+    }
+
+    override fun putInt(key: String, value: Int) {
+        kvPairDao.put(KeyValuePair(key).put(value.toLong()))
+        fireChangeListener(key)
+    }
+
+    override fun putLong(key: String, value: Long) {
+        kvPairDao.put(KeyValuePair(key).put(value))
+        fireChangeListener(key)
+    }
+
+    override fun putString(key: String, value: String?) = if (value == null) remove(key) else {
+        kvPairDao.put(KeyValuePair(key).put(value))
+        fireChangeListener(key)
+    }
+
+    override fun putStringSet(key: String, values: MutableSet<String>?) =
+        if (values == null) remove(key) else {
+            kvPairDao.put(KeyValuePair(key).put(values))
+            fireChangeListener(key)
+        }
+
+    fun remove(key: String) {
+        kvPairDao.delete(key)
+        fireChangeListener(key)
+    }
+
+    private val listeners = HashSet<OnPreferenceDataStoreChangeListener>()
+    private fun fireChangeListener(key: String) =
+        listeners.forEach { it.onPreferenceDataStoreChanged(this, key) }
+
+    fun registerChangeListener(listener: OnPreferenceDataStoreChangeListener) =
+        listeners.add(listener)
+
+    fun unregisterChangeListener(listener: OnPreferenceDataStoreChangeListener) =
+        listeners.remove(listener)
+}

+ 25 - 0
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();
+    }
+
+}

+ 55 - 0
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 extends AbstractBean> 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);
+    }
+
+}

+ 19 - 0
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);
+    }
+
+}

+ 8 - 0
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()

+ 42 - 0
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<T> extends TypeAdapter<JsonLazyInterface<T>> {
+
+    private final Gson gson;
+    private final Class<JsonLazyInterface<T>> clazz;
+
+    public JsonLazyAdapter(Gson gson, Class<JsonLazyInterface<T>> clazz) {
+        this.gson = gson;
+        this.clazz = clazz;
+    }
+
+    @Override
+    public void write(JsonWriter out, JsonLazyInterface<T> value) throws IOException {
+        gson.getAdapter(value.type.getValue()).write(out, value.getValue());
+    }
+
+    @Override
+    public JsonLazyInterface<T> read(JsonReader in) throws IOException {
+        try {
+            JsonLazyInterface<T> 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);
+            }
+        }
+    }
+}

+ 17 - 0
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 <T> TypeAdapter<T> create(Gson gson, TypeToken<T> type) {
+        if (!JsonLazyInterface.class.isAssignableFrom(type.getRawType())) return null;
+        return new JsonLazyAdapter(gson, type.getRawType());
+    }
+
+}

+ 52 - 0
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<T> implements Lazy<T> {
+
+    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<Class<T>> type = LazyKt.lazy(() -> (Class<T>) getType());
+    private final Lazy<T> _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();
+    }
+
+}

+ 30 - 0
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<X, Y> {
+
+    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";
+    }
+}

+ 46 - 0
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<X, Y> extends TypeAdapter<JsonOr<X, Y>> {
+
+    private final Gson gson;
+    private final TypeToken<X> typeX;
+    private final TypeToken<Y> typeY;
+    private final JsonToken tokenX;
+    private final JsonToken tokenY;
+
+    public JsonOrAdapter(Gson gson, TypeToken<X> typeX, TypeToken<Y> 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<X, Y> 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<X, Y> 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));
+        }
+
+    }
+}

+ 32 - 0
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 <T> TypeAdapter<T> create(Gson gson, TypeToken<T> 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);
+        }
+    }
+
+}

+ 31 - 0
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();
+    }
+}

+ 35 - 0
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 = ("http://" + 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")
+
+}

+ 693 - 0
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<String> services;
+
+    }
+
+    public DnsObject dns;
+
+    public static class DnsObject {
+
+        public Map<String, String> hosts;
+
+        public List<StringOrServerObject> servers;
+
+        public static class ServerObject {
+
+            public String address;
+            public Integer port;
+            public String clientIp;
+
+        }
+
+        public static class StringOrServerObject extends JsonOr<String, ServerObject> {
+            public StringOrServerObject() {
+                super(JsonToken.STRING, JsonToken.BEGIN_OBJECT);
+            }
+        }
+
+        public String clientIp;
+        public Boolean disableCache;
+        public String tag;
+        public List<String> domains;
+        public List<String> expectIPs;
+
+    }
+
+    public RoutingObject routing;
+
+    public static class RoutingObject {
+
+        public String domainStrategy;
+        public List<RuleObject> rules;
+
+        public static class RuleObject {
+
+            public String type;
+            public List<String> domain;
+            public List<String> ip;
+            public String port;
+            public String sourcePort;
+            public String network;
+            public List<String> source;
+            public List<String> user;
+            public List<String> inboundTag;
+            public List<String> protocol;
+            public String attrs;
+            public String outboundTag;
+            public String balancerTag;
+
+        }
+
+        public List<BalancerObject> balancers;
+
+        public static class BalancerObject {
+
+            public String tag;
+            public List<String> selector;
+
+        }
+
+    }
+
+    public PolicyObject policy;
+
+    public static class PolicyObject {
+
+        public Map<String, LevelPolicyObject> 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<InboundObject> inbounds;
+
+    public static class InboundObject implements InstanceCreator<InboundObject.LazyInboundConfigurationObject> {
+
+        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<String> 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<InboundConfigurationObject> {
+
+            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<AccountObject> 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<AccountObject> 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<ClientObject> 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<ClientObject> clients;
+        public String decryption;
+        public List<FallbackObject> 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<ClientObject> clients;
+        public List<FallbackObject> 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<OutboundObject> 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<OutboundConfigurationObject> {
+
+            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<ServerObject> servers;
+
+        public static class ServerObject {
+
+            public String address;
+            public Integer port;
+            public List<HTTPInboundConfigurationObject.AccountObject> users;
+
+        }
+
+    }
+
+    public static class SocksOutboundConfigurationObject implements OutboundConfigurationObject {
+
+        public List<ServerObject> servers;
+
+        public static class ServerObject {
+
+            public String address;
+            public Integer port;
+            public List<UserObject> users;
+
+            public static class UserObject {
+
+                public String user;
+                public String pass;
+                public Integer level;
+
+            }
+
+        }
+
+    }
+
+    public static class VMessOutboundConfigurationObject implements OutboundConfigurationObject {
+
+        public List<ServerObject> 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<ServerObject> 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<VMessOutboundConfigurationObject.ServerObject> 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<String> alpn;
+        public List<CertificateObject> certificates;
+        public Boolean disableSystemRoot;
+
+        public static class CertificateObject {
+
+            public String usage;
+            public String certificateFile;
+            public String keyFile;
+            public List<String> certificate;
+            public List<String> 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<String> path;
+                public Map<String, List<String>> headers;
+
+            }
+
+            public static class HTTPResponseObject {
+
+                public String version;
+                public String status;
+                public String reason;
+                public Map<String, List<String>> 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<String, String> headers;
+
+    }
+
+    public static class HttpObject {
+
+        public List<String> 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;
+
+    }
+
+}

+ 77 - 0
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();
+    }
+}

+ 245 - 0
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://")
+
+}

+ 15 - 0
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)

+ 14 - 0
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()
+}

+ 10 - 0
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)

+ 66 - 0
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)
+    }
+
+}

+ 36 - 0
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<T>(
+    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())
+
+}

+ 117 - 0
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 <T> Iterable<T>.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 <T> 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
+    }

+ 24 - 0
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()
+
+    }
+
+}

+ 85 - 0
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<ViewPager2>(R.id.group_pager)
+        val tabLayout = view.findViewById<TabLayout>(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()
+
+    }
+
+}

+ 182 - 0
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<ViewGroup.MarginLayoutParams> {
+                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) [email protected](
+            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()
+    }
+
+}

+ 71 - 0
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<KeyguardManager>()!!.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<Void?, Boolean>() {
+        private var cachedIntent: Intent? = null
+
+        override fun getSynchronousResult(
+            context: Context,
+            input: Void?,
+        ): SynchronousResult<Boolean>? {
+            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
+            }
+    }
+
+
+}

+ 60 - 0
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<ConfigurationHolder>() {
+
+    var configurationList: List<ProxyEntity> = 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
+    }
+
+}

+ 68 - 0
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
+                }
+            }
+        }
+
+    }
+
+}

+ 39 - 0
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<RecyclerView>(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()
+
+    }
+
+}

+ 45 - 0
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<ProxyGroup> = 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 }
+    }
+
+}

+ 44 - 0
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)
+        }
+    }
+}

+ 140 - 0
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.
+ *
+ *
+ * `
+ * <someelement><br></br>
+ * &nbsp;&nbsp;<acommandline executable="/executable/to/run"><br></br>
+ * &nbsp;&nbsp;&nbsp;&nbsp;<argument value="argument 1" /><br></br>
+ * &nbsp;&nbsp;&nbsp;&nbsp;<argument line="argument_1 argument_2 argument_3" /><br></br>
+ * &nbsp;&nbsp;&nbsp;&nbsp;<argument value="argument 4" /><br></br>
+ * &nbsp;&nbsp;</acommandline><br></br>
+ * </someelement><br></br>
+` *
+ *
+ * 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>?): 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<String>) =
+        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<String> {
+        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<String>()
+        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()
+    }
+}

+ 20 - 0
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
+}

+ 86 - 0
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<Subnet> {
+    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<Immutable> {
+            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)
+}

+ 57 - 0
app/src/main/java/io/nekohasekai/sagernet/widget/AutoCollapseTextView.kt

@@ -0,0 +1,57 @@
+/*******************************************************************************
+ *                                                                             *
+ *  Copyright (C) 2018 by Max Lv <[email protected]>                          *
+ *  Copyright (C) 2018 by Mygod Studio <[email protected]>  *
+ *                                                                             *
+ *  This program is free software: you can redistribute it and/or modify       *
+ *  it under the terms of the GNU General Public License as published by       *
+ *  the Free Software Foundation, either version 3 of the License, or          *
+ *  (at your option) any later version.                                        *
+ *                                                                             *
+ *  This program is distributed in the hope that it will be useful,            *
+ *  but WITHOUT ANY WARRANTY; without even the implied warranty of             *
+ *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the              *
+ *  GNU General Public License for more details.                               *
+ *                                                                             *
+ *  You should have received a copy of the GNU General Public License          *
+ *  along with this program. If not, see <http://www.gnu.org/licenses/>.       *
+ *                                                                             *
+ *******************************************************************************/
+
+package io.nekohasekai.sagernet.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
+    }
+}

+ 100 - 0
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<AnimatedVectorDrawableCompat>()
+
+    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()
+        }
+    }
+}

+ 121 - 0
app/src/main/java/io/nekohasekai/sagernet/widget/StatsBar.kt

@@ -0,0 +1,121 @@
+/*******************************************************************************
+ *                                                                             *
+ *  Copyright (C) 2018 by Max Lv <[email protected]>                          *
+ *  Copyright (C) 2018 by Mygod Studio <[email protected]>  *
+ *                                                                             *
+ *  This program is free software: you can redistribute it and/or modify       *
+ *  it under the terms of the GNU General Public License as published by       *
+ *  the Free Software Foundation, either version 3 of the License, or          *
+ *  (at your option) any later version.                                        *
+ *                                                                             *
+ *  This program is distributed in the hope that it will be useful,            *
+ *  but WITHOUT ANY WARRANTY; without even the implied warranty of             *
+ *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the              *
+ *  GNU General Public License for more details.                               *
+ *                                                                             *
+ *  You should have received a copy of the GNU General Public License          *
+ *  along with this program. If not, see <http://www.gnu.org/licenses/>.       *
+ *                                                                             *
+ *******************************************************************************/
+
+package io.nekohasekai.sagernet.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() {
+
+    }
+
+}

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

+ 128 - 0
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)

+ 1 - 0
app/src/main/jni/Application.mk

@@ -0,0 +1 @@
+APP_STL := c++_static

+ 1 - 0
app/src/main/jni/badvpn

@@ -0,0 +1 @@
+Subproject commit fb01eb983915c9a09a690ad44c6028dd87500ec7

+ 31 - 0
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

+ 1 - 0
app/src/main/jni/libancillary

@@ -0,0 +1 @@
+Subproject commit 311e5d14f593f16c785bc6605220517eb1f21f6b

+ 12 - 0
app/src/main/res/drawable-v21/ic_menu_camera.xml

@@ -0,0 +1,12 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportWidth="24.0"
+    android:viewportHeight="24.0">
+    <path
+        android:fillColor="#FF000000"
+        android:pathData="M12,12m-3.2,0a3.2,3.2 0,1 1,6.4 0a3.2,3.2 0,1 1,-6.4 0" />
+    <path
+        android:fillColor="#FF000000"
+        android:pathData="M9,2L7.17,4H4c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V6c0,-1.1 -0.9,-2 -2,-2h-3.17L15,2H9zm3,15c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5 -2.24,5 -5,5z" />
+</vector>

+ 9 - 0
app/src/main/res/drawable-v21/ic_menu_gallery.xml

@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportWidth="24.0"
+    android:viewportHeight="24.0">
+    <path
+        android:fillColor="#FF000000"
+        android:pathData="M22,16V4c0,-1.1 -0.9,-2 -2,-2H8c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2zm-11,-4l2.03,2.71L16,11l4,5H8l3,-4zM2,6v14c0,1.1 0.9,2 2,2h14v-2H4V6H2z" />
+</vector>

+ 9 - 0
app/src/main/res/drawable-v21/ic_menu_slideshow.xml

@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportWidth="24.0"
+    android:viewportHeight="24.0">
+    <path
+        android:fillColor="#FF000000"
+        android:pathData="M4,6H2v14c0,1.1 0.9,2 2,2h14v-2H4V6zm16,-4H8c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2V4c0,-1.1 -0.9,-2 -2,-2zm-8,12.5v-9l6,4.5 -6,4.5z" />
+</vector>

+ 25 - 0
app/src/main/res/drawable/background_profile.xml

@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="utf-8"?>
+<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
+    <item>
+        <shape android:shape="rectangle">
+            <solid android:color="@color/background_stat" />
+        </shape>
+    </item>
+    <item>
+        <selector android:enterFadeDuration="@android:integer/config_mediumAnimTime"
+                  android:exitFadeDuration="@android:integer/config_mediumAnimTime">
+            <item android:state_selected="true">
+                <shape android:shape="rectangle">
+                    <solid android:color="@color/material_pink_700" />
+                </shape>
+            </item>
+        </selector>
+    </item>
+    <!-- android:start not available until API 23 -->
+    <item android:left="8dp">
+        <shape android:shape="rectangle">
+            <solid android:color="?android:colorBackground" />
+        </shape>
+    </item>
+    <item android:drawable="?android:attr/selectableItemBackground"/>
+</layer-list>

+ 12 - 0
app/src/main/res/drawable/background_selectable.xml

@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8"?>
+<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
+    <item>
+        <selector android:enterFadeDuration="@android:integer/config_mediumAnimTime"
+                  android:exitFadeDuration="@android:integer/config_mediumAnimTime">
+            <item android:state_selected="true">
+                <color android:color="@color/background_selected"/>
+            </item>
+        </selector>
+    </item>
+    <item android:drawable="?android:attr/selectableItemBackground"/>
+</layer-list>

+ 10 - 0
app/src/main/res/drawable/ic_action_assignment.xml

@@ -0,0 +1,10 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+        android:width="24dp"
+        android:height="24dp"
+        android:autoMirrored="true"
+        android:viewportWidth="24.0"
+        android:viewportHeight="24.0">
+    <path
+        android:fillColor="#FF000000"
+        android:pathData="M19,3h-4.18C14.4,1.84 13.3,1 12,1c-1.3,0 -2.4,0.84 -2.82,2L5,3c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2L21,5c0,-1.1 -0.9,-2 -2,-2zM12,3c0.55,0 1,0.45 1,1s-0.45,1 -1,1 -1,-0.45 -1,-1 0.45,-1 1,-1zM14,17L7,17v-2h7v2zM17,13L7,13v-2h10v2zM17,9L7,9L7,7h10v2z"/>
+</vector>

+ 9 - 0
app/src/main/res/drawable/ic_action_copyright.xml

@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+        android:width="24dp"
+        android:height="24dp"
+        android:viewportWidth="24.0"
+        android:viewportHeight="24.0">
+    <path
+        android:fillColor="#FF000000"
+        android:pathData="M10.08,10.86c0.05,-0.33 0.16,-0.62 0.3,-0.87s0.34,-0.46 0.59,-0.62c0.24,-0.15 0.54,-0.22 0.91,-0.23 0.23,0.01 0.44,0.05 0.63,0.13 0.2,0.09 0.38,0.21 0.52,0.36s0.25,0.33 0.34,0.53 0.13,0.42 0.14,0.64h1.79c-0.02,-0.47 -0.11,-0.9 -0.28,-1.29s-0.4,-0.73 -0.7,-1.01 -0.66,-0.5 -1.08,-0.66 -0.88,-0.23 -1.39,-0.23c-0.65,0 -1.22,0.11 -1.7,0.34s-0.88,0.53 -1.2,0.92 -0.56,0.84 -0.71,1.36S8,11.29 8,11.87v0.27c0,0.58 0.08,1.12 0.23,1.64s0.39,0.97 0.71,1.35 0.72,0.69 1.2,0.91 1.05,0.34 1.7,0.34c0.47,0 0.91,-0.08 1.32,-0.23s0.77,-0.36 1.08,-0.63 0.56,-0.58 0.74,-0.94 0.29,-0.74 0.3,-1.15h-1.79c-0.01,0.21 -0.06,0.4 -0.15,0.58s-0.21,0.33 -0.36,0.46 -0.32,0.23 -0.52,0.3c-0.19,0.07 -0.39,0.09 -0.6,0.1 -0.36,-0.01 -0.66,-0.08 -0.89,-0.23 -0.25,-0.16 -0.45,-0.37 -0.59,-0.62s-0.25,-0.55 -0.3,-0.88 -0.08,-0.67 -0.08,-1v-0.27c0,-0.35 0.03,-0.68 0.08,-1.01zM12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8 8,3.59 8,8 -3.59,8 -8,8z"/>
+</vector>

+ 10 - 0
app/src/main/res/drawable/ic_action_delete.xml

@@ -0,0 +1,10 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+        android:width="24dp"
+        android:height="24dp"
+        android:viewportWidth="24.0"
+        android:viewportHeight="24.0"
+        android:tint="?attr/colorControlNormal">
+    <path
+            android:fillColor="#FFFFFFFF"
+            android:pathData="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z"/>
+</vector>

+ 10 - 0
app/src/main/res/drawable/ic_action_description.xml

@@ -0,0 +1,10 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+        android:width="24dp"
+        android:height="24dp"
+        android:autoMirrored="true"
+        android:viewportWidth="24.0"
+        android:viewportHeight="24.0">
+    <path
+        android:fillColor="#FF000000"
+        android:pathData="M14,2L6,2c-1.1,0 -1.99,0.9 -1.99,2L4,20c0,1.1 0.89,2 1.99,2L18,22c1.1,0 2,-0.9 2,-2L20,8l-6,-6zM16,18L8,18v-2h8v2zM16,14L8,14v-2h8v2zM13,9L13,3.5L18.5,9L13,9z"/>
+</vector>

+ 10 - 0
app/src/main/res/drawable/ic_action_dns.xml

@@ -0,0 +1,10 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+        android:width="24dp"
+        android:height="24dp"
+        android:viewportWidth="24.0"
+        android:viewportHeight="24.0"
+        android:tint="?attr/colorControlNormal">
+    <path
+        android:fillColor="#FF000000"
+        android:pathData="M20,13H4c-0.55,0 -1,0.45 -1,1v6c0,0.55 0.45,1 1,1h16c0.55,0 1,-0.45 1,-1v-6c0,-0.55 -0.45,-1 -1,-1zM7,19c-1.1,0 -2,-0.9 -2,-2s0.9,-2 2,-2 2,0.9 2,2 -0.9,2 -2,2zM20,3H4c-0.55,0 -1,0.45 -1,1v6c0,0.55 0.45,1 1,1h16c0.55,0 1,-0.45 1,-1V4c0,-0.55 -0.45,-1 -1,-1zM7,9c-1.1,0 -2,-0.9 -2,-2s0.9,-2 2,-2 2,0.9 2,2 -0.9,2 -2,2z"/>
+</vector>

+ 10 - 0
app/src/main/res/drawable/ic_action_done.xml

@@ -0,0 +1,10 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+        android:width="24dp"
+        android:height="24dp"
+        android:viewportWidth="24.0"
+        android:viewportHeight="24.0"
+        android:tint="?attr/colorControlNormal">
+    <path
+            android:fillColor="#FF000000"
+            android:pathData="M9,16.2L4.8,12l-1.4,1.4L9,19 21,7l-1.4,-1.4L9,16.2z"/>
+</vector>

+ 9 - 0
app/src/main/res/drawable/ic_action_help_outline.xml

@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+        android:width="24dp"
+        android:height="24dp"
+        android:viewportWidth="24.0"
+        android:viewportHeight="24.0">
+    <path
+        android:fillColor="#FF000000"
+        android:pathData="M11,18h2v-2h-2v2zM12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8 8,3.59 8,8 -3.59,8 -8,8zM12,6c-2.21,0 -4,1.79 -4,4h2c0,-1.1 0.9,-2 2,-2s2,0.9 2,2c0,2 -3,1.75 -3,5h2c0,-2.25 3,-2.5 3,-5 0,-2.21 -1.79,-4 -4,-4z"/>
+</vector>

+ 10 - 0
app/src/main/res/drawable/ic_action_lock.xml

@@ -0,0 +1,10 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+        android:width="24dp"
+        android:height="24dp"
+        android:viewportWidth="24.0"
+        android:viewportHeight="24.0"
+        android:tint="?attr/colorControlNormal">
+    <path
+        android:fillColor="#FF000000"
+        android:pathData="M18,8h-1L17,6c0,-2.76 -2.24,-5 -5,-5S7,3.24 7,6v2L6,8c-1.1,0 -2,0.9 -2,2v10c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2L20,10c0,-1.1 -0.9,-2 -2,-2zM12,17c-1.1,0 -2,-0.9 -2,-2s0.9,-2 2,-2 2,0.9 2,2 -0.9,2 -2,2zM15.1,8L8.9,8L8.9,6c0,-1.71 1.39,-3.1 3.1,-3.1 1.71,0 3.1,1.39 3.1,3.1v2z"/>
+</vector>

+ 6 - 0
app/src/main/res/drawable/ic_action_lock_open.xml

@@ -0,0 +1,6 @@
+<vector android:autoMirrored="true" android:height="24dp"
+    android:tint="?attr/colorControlNormal"
+    android:viewportHeight="24.0" android:viewportWidth="24.0"
+    android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
+    <path android:fillColor="@android:color/white" android:pathData="M12,17c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM18,8h-1L17,6c0,-2.76 -2.24,-5 -5,-5S7,3.24 7,6h1.9c0,-1.71 1.39,-3.1 3.1,-3.1 1.71,0 3.1,1.39 3.1,3.1v2L6,8c-1.1,0 -2,0.9 -2,2v10c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2L20,10c0,-1.1 -0.9,-2 -2,-2zM18,20L6,20L6,10h12v10z"/>
+</vector>

Vissa filer visades inte eftersom för många filer har ändrats